赞扬以测试为指导的面向对象软件的发展

Praise for Growing Object-Oriented Software, Guided by Tests

“本书的作者通过控制软件成长的环境,引领了编程工艺的革命。他们的培养皿是模拟对象,他们的显微镜是单元测试。本书可以向您展示这些工具如何为您的工作带来可重复性,这是任何科学家都会羡慕的。”

“The authors of this book have led a revolution in the craft of programming by controlling the environment in which software grows. Their Petri dish is the mock object, and their microscope is the unit test. This book can show you how these tools introduce a repeatability to your work that would be the envy of any scientist.”

沃德·坎宁安

Ward Cunningham

“终于有一本充满代码的书揭示了 TDD 和 OOD 之间的深层共生关系。作者是测试驱动开发的先驱,他们在书中融入了原则、实践、启发式方法,以及(最好的)从他们数十年的专业经验中总结出来的趣闻轶事。每个软件工匠都想仔细阅读实例章节,研究高级测试和设计原则。这本书值得珍藏。”

“At last a book, suffused with code, that exposes the deep symbiosis between TDD and OOD. The authors, pioneers in test-driven development, have packed it with principles, practices, heuristics, and (best of all) anecdotes drawn from their decades of professional experience. Every software craftsman will want to pore over the chapters of worked examples and study the advanced testing and design principles. This one’s a keeper.”

罗伯特·C·马丁

Robert C. Martin

“设计经常被深入讨论,但没有经验主义。测试经常被提倡,但质量定义很狭隘,只与缺陷的存在与否有关。这两种观点都很有价值,但单独来看,它们的作用无异于单掌鼓掌。史蒂夫和纳特将两只手合在一起,这值得——也最好被描述为——掌声。他们以清晰、理性和幽默的视角,揭示了对设计、测试、代码、对象、实践和过程的看法,这种看法引人注目、实用且充满洞察力。”

“Design is often discussed in depth, but without empiricism. Testing is often promoted, but within the narrow definition of quality that relates only to the presence or absence of defects. Both of these perspectives are valuable, but each on its own offers little more than the sound of one hand clapping. Steve and Nat bring the two hands together in what deserves—and can best be described as—applause. With clarity, reason, and humour, their tour de force reveals a view of design, testing, code, objects, practice, and process that is compelling, practical, and overflowing with insight.”

Kevlin Henney , 《面向模式的软件架构》《每个程序员都应该知道的 97 件事》合著者

Kevlin Henney, co-author of Pattern-Oriented Software Architecture and 97 Things Every Programmer Should Know

“史蒂夫和纳特写了一本很棒的书,与世界分享了他们的软件工艺。这是一本值得研究而不是阅读的书,那些投入足够时间和精力的人将获得卓越的开发技能。”

“Steve and Nat have written a wonderful book that shares their software craftsmanship with the rest of the world. This is a book that should be studied rather than read, and those who invest sufficient time and energy into this effort will be rewarded with superior development skills.”

David Vydratestdriven.com出版商

David Vydra, publisher, testdriven.com

“本书展示了测试驱动开发的独特视角。它描述了 21 世纪初在伦敦兴起的另一种 TDD 的成熟形式,其特点是完全端到端的方法,并高度重视对象的消息传递方面。如果您想成为 TDD 领域最前沿的专家,您需要理解本书中的想法。”

“This book presents a unique vision of test-driven development. It describes the mature form of an alternative strain of TDD that sprang up in London in the early 2000s, characterized by a totally end-to-end approach and a deep emphasis on the messaging aspect of objects. If you want to be an expert in the state of the art in TDD, you need to understand the ideas in this book.”

迈克尔·费瑟斯

Michael Feathers

“通过这本书,您将从大师那里学习到开发经过测试、设计良好的面向对象应用程序的节奏、思维细微差别和有效的编程实践。”

“With this book you’ll learn the rhythms, nuances in thinking, and effective programming practices for growing tested, well-designed object-oriented applications from the masters.”

丽贝卡·维尔夫斯-布洛克

Rebecca Wirfs-Brock

在测试的指导下发展面向对象软件

Growing Object-Oriented Software, Guided by Tests

史蒂夫·弗里曼和纳特·普赖斯

Steve Freeman and Nat Pryce

图像

新泽西州上萨德尔河 • 波士顿 • 印第安纳波利斯 • 旧金山

纽约 • 多伦多 • 蒙特利尔 • 伦敦 • 慕尼黑 • 巴黎 • 马德里

开普敦 • 悉尼 • 东京 • 新加坡 • 墨西哥城

Upper Saddle River, NJ • Boston • Indianapolis • San Francisco

New York • Toronto • Montreal • London • Munich • Paris • Madrid

Cape Town • Sydney • Tokyo • Singapore • Mexico City

制造商和销售商用来区分其产品的许多名称均已声明为商标。本书中出现这些名称时,如果出版商知道商标声明,则这些名称将以首字母大写或全部大写形式印刷。

Many of the designations used by manufacturers and sellers to distinguish their products are claimed as trademarks. Where those designations appear in this book, and the publisher was aware of a trademark claim, the designations have been printed with initial capital letters or in all capitals.

作者和出版商在编写本书时已尽心尽力,但不作任何明示或暗示的保证,也不对错误或遗漏承担任何责任。对于因使用本文所含信息或程序而导致的或与之相关的偶然或间接损失,我们不承担任何责任。

The authors and publisher have taken care in the preparation of this book, but make no expressed or implied warranty of any kind and assume no responsibility for errors or omissions. No liability is assumed for incidental or consequential damages in connection with or arising out of the use of the information or programs contained herein.

出版商为批量订购此书或特价销售提供优惠折扣,可能包括电子版和/或定制封面和内容,这些内容特定于您的业务、培训目标、营销重点和品牌兴趣。欲了解更多信息,请联系:美国企业和政府销售部

(800) 382–3419

corpsales@pearsontechgroup.com

The publisher offers excellent discounts on this book when ordered in quantity for bulk purchases or special sales, which may include electronic versions and/or custom covers and content particular to your business, training goals, marketing focus, and branding interests. For more information, please contact: U.S. Corporate and Government Sales

(800) 382–3419

corpsales@pearsontechgroup.com

对于美国境外的销售,请联系:

For sales outside the United States please contact:

国际销售

international@pearson.com

International Sales

international@pearson.com

请访问我们的网站:informit.com/aw

Visit us on the Web: informit.com/aw

美国国会图书馆出版编目数据:

Library of Congress Cataloging-in-Publication Data:

Freeman, Steve,1958-

不断发展的面向对象软件,由测试指导 / Steve Freeman 和 Nat Pryce。p

. cm。ISBN

978-0-321-50362-6(平装本:简装本)1. 面向对象编程

(计算机科学)2. 计算机软件--测试。I. Pryce,Nat. II。标题。QA76.64.F747

2010

005.1'17--dc22

2009035239

Freeman, Steve, 1958-

Growing object-oriented software, guided by tests / Steve Freeman and Nat Pryce.

p. cm.

ISBN 978-0-321-50362-6 (pbk. : alk. paper) 1. Object-oriented programming

(Computer science) 2. Computer software--Testing. I. Pryce, Nat. II. Title.

QA76.64.F747 2010

005.1’17--dc22

2009035239

版权所有 © 2010 Pearson Education, Inc.

Copyright © 2010 Pearson Education, Inc.

保留所有权利。印刷于美国。本出版物受版权保护,任何禁止复制、存储在检索系统中或以任何形式或任何手段(电子、机械、影印、录制或类似方式)传输之前,必须获得出版商的许可。有关许可的信息,请写信至:Pearson Education, Inc

权利和合同部

501 Boylston Street, Suite 900

Boston, MA 02116

传真 (617) 671 3447

All rights reserved. Printed in the United States of America. This publication is protected by copyright, and permission must be obtained from the publisher prior to any prohibited reproduction, storage in a retrieval system, or transmission in any form or by any means, electronic, mechanical, photocopying, recording, or likewise. For information regarding permissions, write to: Pearson Education, Inc

Rights and Contracts Department

501 Boylston Street, Suite 900

Boston, MA 02116

Fax (617) 671 3447

ISBN-13:978–0–321–50362-6

ISBN-10:0–321–50362–7

文本在美国印第安纳州克劳福兹维尔的 RR Donnelley 以再生纸印刷。

第六次印刷于 2012 年 6 月

ISBN-13: 978–0–321–50362-6

ISBN-10: 0–321–50362–7

Text printed in the United States on recycled paper at RR Donnelley in Crawfordsville, Indiana.

Sixth printing June 2012

感谢 Paola 一直以来的支持;感谢 Philip,他有时会错过

To Paola, for all her support; to Philip, who sometimes missed out

—史蒂夫

—Steve

感谢 Lamaan 忍受我花时间写这本书,感谢 Oliver Tarek 没有

To Lamaan who put up with me spending time writing this book, and Oliver Tarek who did not

—纳特 (Nat)

—Nat

内容

Contents

前言

Foreword

前言

Preface

致谢

Acknowledgments

关于作者

About the Authors

第一部分:简介

Part I: Introduction

第 1 章:测试驱动开发的意义是什么?

Chapter 1: What Is the Point of Test-Driven Development?

软件开发作为学习过程

Software Development as a Learning Process

反馈是基本工具

Feedback Is the Fundamental Tool

支持变革的实践

Practices That Support Change

测试驱动开发简介

Test-Driven Development in a Nutshell

更大的图景

The Bigger Picture

端到端测试

Testing End-to-End

测试级别

Levels of Testing

外部和内部质量

External and Internal Quality

第 2 章:使用对象进行测试驱动开发

Chapter 2: Test-Driven Development with Objects

对象网络

A Web of Objects

值和对象

Values and Objects

关注消息

Follow the Messages

告诉,不要询问

Tell, Don’t Ask

但有时要问

But Sometimes Ask

对协作对象进行单元测试

Unit-Testing the Collaborating Objects

使用 Mock 对象支持 TDD

Support for TDD with Mock Objects

第 3 章:工具介绍

Chapter 3: An Introduction to the Tools

如果你以前听过这个,就阻止我

Stop Me If You’ve Heard This One Before

JUnit 4 简介

A Minimal Introduction to JUnit 4

Hamcrest Matchers 和 assertThat()

Hamcrest Matchers and assertThat()

jMock2:模拟对象

jMock2: Mock Objects

第二部分:测试驱动开发的过程

Part II: The Process of Test-Driven Development

第 4 章:启动测试驱动周期

Chapter 4: Kick-Starting the Test-Driven Cycle

介绍

Introduction

首先,测试行走骨架

First, Test a Walking Skeleton

决定行走骨骼的形状

Deciding the Shape of the Walking Skeleton

建立反馈来源

Build Sources of Feedback

尽早暴露不确定性

Expose Uncertainty Early

第 5 章:维护测试驱动周期

Chapter 5: Maintaining the Test-Driven Cycle

介绍

Introduction

每个功能都通过验收测试开始

Start Each Feature with an Acceptance Test

将衡量进步的测试与捕捉倒退的测试区分开来

Separate Tests That Measure Progress from Those That Catch Regressions

从最简单的成功案例开始测试

Start Testing with the Simplest Success Case

编写您想要阅读的测试

Write the Test That You’d Want to Read

观察测试失败

Watch the Test Fail

从输入到输出的发展

Develop from the Inputs to the Outputs

对行为进行单元测试,而不是对方法进行单元测试

Unit-Test Behavior, Not Methods

聆听测试

Listen to the Tests

调整周期

Tuning the Cycle

第 6 章:面向对象风格

Chapter 6: Object-Oriented Style

介绍

Introduction

可维护性设计

Designing for Maintainability

内部与同行

Internals vs. Peers

没有“并且”、“或者”或“但是”

No And’s, Or’s, or But’s

对象同侪刻板印象

Object Peer Stereotypes

复合材料比其各部分之和更简单

Composite Simpler Than the Sum of Its Parts

上下文独立性

Context Independence

隐藏正确信息

Hiding the Right Information

主观观点

An Opinionated View

第 7 章:实现面向对象设计

Chapter 7: Achieving Object-Oriented Design

编写测试如何帮助设计

How Writing a Test First Helps the Design

分类沟通

Communication over Classification

值类型

Value Types

物体从哪里来?

Where Do Objects Come From?

识别与接口的关系

Identify Relationships with Interfaces

重构接口

Refactor Interfaces Too

组合对象来描述系统行为

Compose Objects to Describe System Behavior

构建更高级的编程

Building Up to Higher-Level Programming

那么课程又如何呢?

And What about Classes?

第 8 章:基于第三方代码构建

Chapter 8: Building on Third-Party Code

介绍

Introduction

仅模拟您拥有的类型

Only Mock Types That You Own

在集成测试中模拟应用程序对象

Mock Application Objects in Integration Tests

第三部分:示例

Part III: A Worked Example

第 9 章:委托拍卖狙击手

Chapter 9: Commissioning an Auction Sniper

从头开始

To Begin at the Beginning

与拍卖行沟通

Communicating with an Auction

安全到达

Getting There Safely

这不是真的

This Isn’t Real

第10章 行走的骷髅

Chapter 10: The Walking Skeleton

把骷髅从衣柜里拿出来

Get the Skeleton out of the Closet

我们的第一次测试

Our Very First Test

一些初步选择

Some Initial Choices

第11章:通过第一场考验

Chapter 11: Passing the First Test

搭建测试台

Building the Test Rig

考试失败和通过

Failing and Passing the Test

必要的最低限度

The Necessary Minimum

第 12 章:准备竞标

Chapter 12: Getting Ready to Bid

市场介绍

An Introduction to the Market

竞标测试

A Test for Bidding

拍卖信息翻译器

The AuctionMessageTranslator

解读价格信息

Unpacking a Price Message

完成工作

Finish the Job

第13章 狙击手出价

Chapter 13: The Sniper Makes a Bid

AuctionSniper 简介

Introducing AuctionSniper

发送投标

Sending a Bid

整理实施

Tidying Up the Implementation

推迟决策

Defer Decisions

新兴设计

Emergent Design

第14章 狙击手赢得拍卖

Chapter 14: The Sniper Wins the Auction

一、测试失败

First, a Failing Test

谁了解竞标者?

Who Knows about Bidders?

狙击手还有话要说

The Sniper Has More to Say

狙击手获得一些状态

The Sniper Acquires Some State

狙击手获胜

The Sniper Wins

稳步前进

Making Steady Progress

第 15 章:迈向真实的用户界面

Chapter 15: Towards a Real User Interface

更现实的实施

A More Realistic Implementation

显示价格详情

Displaying Price Details

简化狙击手活动

Simplifying Sniper Events

跟进

Follow Through

最终润色

Final Polish

观察结果

Observations

第16章 狙击多个物品

Chapter 16: Sniping for Multiple Items

测试多个项目

Testing for Multiple Items

通过用户界面添加项目

Adding Items through the User Interface

观察结果

Observations

第 17 章: 挑逗主线

Chapter 17: Teasing Apart Main

寻找角色

Finding a Role

提取聊天内容

Extracting the Chat

提取连接

Extracting the Connection

提取 SnipersTableModel

Extracting the SnipersTableModel

观察结果

Observations

第 18 章:填写详细信息

Chapter 18: Filling In the Details

更有用的应用程序

A More Useful Application

受够了就停下来

Stop When We’ve Had Enough

观察结果

Observations

第 19 章:处理失败

Chapter 19: Handling Failure

如果它不起作用怎么办?

What If It Doesn’t Work?

检测故障

Detecting the Failure

显示失败

Displaying the Failure

断开狙击手

Disconnecting the Sniper

记录失败

Recording the Failure

观察结果

Observations

第四部分:可持续的测试驱动开发

Part IV: Sustainable Test-Driven Development

第 20 章:聆听测试

Chapter 20: Listening to the Tests

介绍

Introduction

我需要模拟一个无法替换的对象(没有魔法)

I Need to Mock an Object I Can’t Replace (without Magic)

日志记录是一项功能

Logging Is a Feature

模拟具体类

Mocking Concrete Classes

不要模拟值

Don’t Mock Values

臃肿的构造函数

Bloated Constructor

困惑的对象

Confused Object

依赖过多

Too Many Dependencies

期望太多

Too Many Expectations

测试会告诉我们什么(如果我们仔细聆听)

What the Tests Will Tell Us (If We’re Listening)

第 21 章:测试可读性

Chapter 21: Test Readability

介绍

Introduction

测试名称描述特征

Test Names Describe Features

规范测试结构

Canonical Test Structure

简化测试代码

Streamline the Test Code

断言和期望

Assertions and Expectations

文字和变量

Literals and Variables

第 22 章:构建复杂测试数据

Chapter 22: Constructing Complex Test Data

介绍

Introduction

测试数据构建器

Test Data Builders

创建类似对象

Creating Similar Objects

结合建造者

Combining Builders

使用工厂方法强调领域模型

Emphasizing the Domain Model with Factory Methods

在使用点消除重复

Removing Duplication at the Point of Use

沟通第一

Communication First

第 23 章:测试诊断

Chapter 23: Test Diagnostics

设计注定失败

Design to Fail

小型、有重点、命名恰当的测试

Small, Focused, Well-Named Tests

解释性断言消息

Explanatory Assertion Messages

使用匹配器突出显示细节

Highlight Detail with Matchers

自我描述价值

Self-Describing Value

显然罐装价值

Obviously Canned Value

追踪对象

Tracer Object

明确断言期望得到满足

Explicitly Assert That Expectations Were Satisfied

诊断是一流的功能

Diagnostics Are a First-Class Feature

第 24 章:测试灵活性

Chapter 24: Test Flexibility

介绍

Introduction

测试信息,而不是表述

Test for Information, Not Representation

精确断言

Precise Assertions

精确的期望

Precise Expectations

“豚鼠”物品

“Guinea Pig” Objects

第五部分:高级主题

Part V: Advanced Topics

第 25 章:测试持久性

Chapter 25: Testing Persistence

介绍

Introduction

隔离影响持久状态的测试

Isolate Tests That Affect Persistent State

明确测试事务边界

Make Tests Transaction Boundaries Explicit

测试执行持久性操作的对象

Testing an Object That Performs Persistence Operations

测试对象是否可以持久保存

Testing That Objects Can Be Persisted

但是数据库测试很慢!

But Database Tests Are S-l-o-w!

第 26 章:单元测试和线程

Chapter 26: Unit Testing and Threads

介绍

Introduction

分离功能和并发策略

Separating Functionality and Concurrency Policy

单元测试同步

Unit-Testing Synchronization

对被动对象进行压力测试

Stress-Testing Passive Objects

将测试线程与后台线程同步

Synchronizing the Test Thread with Background Threads

单位压力测试的局限性

The Limitations of Unit Stress Tests

第 27 章:测试异步代码

Chapter 27: Testing Asynchronous Code

介绍

Introduction

采样或聆听

Sampling or Listening

两种实现方式

Two Implementations

失控测试

Runaway Tests

丢失更新

Lost Updates

测试操作是否无效

Testing That an Action Has No Effect

区分同步和断言

Distinguish Synchronizations and Assertions

外部化事件源

Externalize Event Sources

后记:Mock 对象简史

Afterword: A Brief History of Mock Objects

附录 A:jMock2 备忘单

Appendix A: jMock2 Cheat Sheet

附录 B:编写 Hamcrest Matcher

Appendix B: Writing a Hamcrest Matcher

参考书目

Bibliography

指数

Index

前言

Foreword

肯特贝克

Kent Beck

发布周期越来越短所带来的难题之一就是如何在更短的时间内发布更多软件 — 并无限期地继续发布。解决这一难题需要一种新的视角。需要的不仅仅是技术上的转变。

One of the dilemmas posed by the move to shorter and shorter release cycles is how to release more software in less time—and continue releasing indefinitely. A new perspective is necessary to resolve this dilemma. More than a shift in techniques is needed.

《在测试的指导下开发面向对象软件》提出了这样一种新视角。如果软件不是像我们制作纸飞机那样“制造”的——折好它然后让它飞走,会怎么样?如果我们把软件看作一种有价值的、多产的植物,需要培育、修剪、收割、施肥和浇水,会怎么样?传统的农民知道如何让植物保持几十年甚至几百年的生产力。如果我们以同样的方式对待我们的程序,软件开发会有什么不同?

Growing Object-Oriented Software, Guided by Tests presents such a new perspective. What if software wasn’t “made,” like we make a paper airplane—finish folding it and fly it away? What if, instead, we treated software more like a valuable, productive plant, to be nurtured, pruned, harvested, fertilized, and watered? Traditional farmers know how to keep plants productive for decades or even centuries. How would software development be different if we treated our programs the same way?

这本书最让我印象深刻的是它如何呈现这种观点转变的哲学和机制。这本书是由编程和教别人编程的从业者撰写的。从中你可以学到如何编程以保持生产力以及如何重新审视你的程序。

I am most impressed by how this book presents both the philosophy and mechanics of such a shift in perspective. It is written by practitioners who code—and teach others to code—well. From it you can learn both how to program to sustain productivity and how to look at your programs anew.

这里介绍的测试驱动开发风格与我实践的有所不同。我还不能清楚地表达出其中的区别,但我从作者清晰、自信地介绍技术中学到了很多东西。方言的多样性给了我一个新的想法来源,让我进一步完善自己的开发。《在测试的指导下发展面向对象软件》提出了一个连贯、一致的开发系统,其中不同的技术相互支持。

The style of test-driven development presented here is different from what I practice. I can’t yet articulate the difference, but I have learned from the clear, confident presentation of the authors’ techniques. The diversity of dialects has given me a new source of ideas to further refine my own development. Growing Object-Oriented Software, Guided by Tests, presents a coherent, consistent system of development, where different techniques support each other.

我邀请您阅读《在测试的指导下发展面向对象软件》,跟随这些示例,了解作者如何看待编程以及他们如何编程。这种体验将丰富您的软件开发风格,帮助您编程——同样重要的是,以不同的方式看待您的程序。

I invite you to read Growing Object-Oriented Software, Guided by Tests, to follow along with the examples, to learn how the authors think about programming and how they program. The experience will enrich your software development style, help you program—and, just as important, see your programs differently.

前言

Preface

这本书是关于什么的?

What Is This Book About?

本书是一本实用指南,介绍了我们找到的编写面向对象软件的最佳方法:测试驱动开发 (TDD)。它描述了我们遵循的流程、我们努力遵循的设计原则以及我们使用的工具。它基于我们数十年的经验,与世界上一些最优秀的程序员一起工作并向他们学习。

This book is a practical guide to the best way we’ve found to write object-oriented software: test-driven development (TDD). It describes the processes we follow, the design principles we strive for, and the tools we use. It’s founded on our decades of experience, working with and learning from some of the best programmers in the world.

在本书中,我们解答了在项目中遇到的一些问题和困惑。如何将测试驱动开发融入软件项目?从哪里开始?为什么我应该同时编写单元测试和端到端测试?测试“驱动”开发意味着什么?如何测试困难的功能 X

Within the book, we address some of the questions and confusions we see coming up on project after project. How do I fit test-driven development into a software project? Where do I start? Why should I write both unit and end-to-end tests? What does it mean for tests to “drive” development? How do I test difficult feature X?

这本书也非常注重设计,以及我们的设计方法如何影响我们的 TDD 方法。如果说我们学到了什么,那就是测试驱动开发作为一个整体时效果最好。我们见过一些团队可以进行原始实践(编写和运行测试),但结果却很艰难,因为他们还没有采用其背后的更深层次的流程。

This book is also very much about design and the way our approach to design informs our approach to TDD. If there’s one thing we’ve learned, it’s that test-driven development works best when taken as a whole. We’ve seen teams that can do the raw practices (writing and running tests) but struggle with the result because they haven’t also adopted the deeper processes that lie behind it.

为什么要“发展”面向对象软件?

Why “Growing” Object-Oriented Software?

我们之所以使用“成长”一词,是因为它让我们感受到我们是如何逐步开发的。我们始终让某些东西运转良好,确保代码始终尽可能结构良好并经过彻底测试。似乎没有其他方法能够像这种方法一样有效地交付有效的系统。正如 John Gall 在 [Gall03] 中写道:“一个有效的复杂系统总是从一个有效的简单系统发展而来。”

We used the term “growing” because it gives a sense of how we develop incrementally. We have something working at all times, making sure that the code is always as well-structured as possible and thoroughly tested. Nothing else seems to be as effective at delivering systems that work. As John Gall wrote in [Gall03], “A complex system that works is invariably found to have evolved from a simple system that works.”

“成长”也暗示了我们在优秀软件中看到的生物品质,即结构各个层面的连贯性。它与我们对待对象的方式息息相关遵循 Alan Kay 的1物体的概念类似于相互发送信息的生物细胞。

“Growing” also hints at the biological quality we see in good software, the sense of coherence at every level of structure. It ties into our approach to object orientation which follows Alan Kay’s1 concept of objects being similar to biological cells that send each other messages.

1.Alan Kay 是 Smalltalk 的作者之一,他创造了“面向对象”一词。

1. Alan Kay was one of the authors of Smalltalk and coined the term “object-oriented.”

为什么要以测试为“指导”?

Why “Guided” by Tests?

我们首先编写测试,因为我们发现它有助于我们编写更好的代码。首先编写测试迫使我们明确我们的意图,并且只有在我们对它应该做什么有明确的描述后,我们才会开始下一项工作。首先编写测试的过程可以帮助我们了解设计何时过于僵化或缺乏重点。然后,当我们想要跟进并修复设计缺陷时,测试会为我们提供一个回归覆盖的安全网。

We write tests first because we find that it helps us write better code. Writing a test first forces us to clarify our intentions, and we don’t start the next piece of work until we have an unambiguous description of what it should do. The process of writing a test first helps us see when a design is too rigid or unfocused. Then, when we want to follow through and fix a design flaw, the tests give us a safety net of regression coverage.

我们使用“引导”一词是因为该技术仍然需要技能和经验。我们发现,一旦我们学会了如何逐步开发和“倾听测试”,测试驱动开发就是一种有效的设计支持工具。与任何严肃的设计活动一样,TDD 需要理解和持续的努力才能发挥作用。

We use the term “guided” because the technique still requires skill and experience. We found test-driven development to be an effective design support tool—once we’d learned how to develop incrementally and to “listen to the tests.” Like any serious design activity, TDD requires understanding and sustained effort to work.

我们看到过一些团队同时编写测试和代码(甚至一些团队先编写测试),但代码却一团糟,而测试只会增加维护成本。他们已经开始了,但还没有学会诀窍,正如书名所暗示的那样,就是让测试指导开发。利用测试的内容专注于取得进展,并利用测试的反馈来提高系统的质量。

We’ve seen teams that write tests and code at about the same time (and even teams that write the tests first) where the code is a mess and the tests just raise the cost of maintenance. They’d made a start but hadn’t yet learned that the trick, as the title of the book suggests, is to let the tests guide development. Use the contents of the tests to stay focused on making progress and feedback from the tests to raise the quality of the system.

那么模拟对象 (Mock Objects) 怎么样?

What about Mock Objects?

我们写这本书的最初动机是为了最终解释使用模拟对象的技术,2我们经常看到有人对此产生误解。随着我们深入写作,我们意识到我们的社区对模拟对象的发现和使用实际上是我们编写软件方法的一种表达;它是更大图景的一部分。

Our original motivation for writing the book was to finally explain the technique of using mock objects,2 which we often see misunderstood. As we got deeper into writing, we realized that our community’s discovery and use of mock objects was actually an expression of our approach to writing software; it’s part of a larger picture.

2.模拟对象是用于测试一个对象如何与其邻居交互的替代实现。

2. Mock objects are substitute implementations for testing how an object interacts with its neighbors.

在本书中,我们将使用 jMock 库展示模拟对象技术的工作原理。更具体地说,我们将展示它在 TDD 过程中的位置以及它在面向对象开发环境中的意义。

In the course of the book, we will show how the mock objects technique works, using the jMock library. More specifically, we’ll show where it fits into the TDD process and how it makes sense in the context of object-oriented development.

这本书适合哪些人阅读?

Who Is This Book For?

我们为“知情读者”撰写了这本书。它面向具有专业经验的开发人员,他们可能至少看过测试驱动的发展。写作时,我们想象自己正在向一位以前没有接触过这些技术的同事解释这些技术。

We wrote this book for the “informed reader.” It’s intended for developers with professional experience who probably have at least looked at test-driven development. When writing, we imagined we were explaining techniques to a colleague who hadn’t come across them before.

为了给我们想要涵盖的更深入的材料留出空间,我们假设您已经了解了一些基本概念和工具;还有其他书籍对 TDD 进行了很好的介绍。

To make room for the deeper material we wanted to cover, we’ve assumed some knowledge of the basic concepts and tools; there are other books that provide a good introduction to TDD.

这是一本 Java 书籍吗?

Is This a Java Book?

我们始终使用 Java 编程语言,因为它足够通用,我们希望读者至少能够理解这些示例。也就是说,这本书实际上是关于一组适用于任何面向对象环境的技术。

We use the Java programming language throughout because it’s common enough that we expect our readers to be able at least to understand the examples. That said, the book is really about a set of techniques that are applicable to any object-oriented environment.

如果您不使用 Java,那么在许多其他语言中都有与我们使用的测试和模拟库(JUnit 和 jMock)相当的版本,包括 C#、Ruby、Python、Smalltalk、Objective-C 和(令人印象深刻的)C++。甚至还有适用于更遥远的语言(如 Scala)的版本。Java 中还有其他测试和模拟框架。

If you’re not using Java, there are equivalents of testing and mocking libraries we use (JUnit and jMock) in many other languages, including C#, Ruby, Python, Smalltalk, Objective-C, and (impressively) C++. There are even versions for more distant languages such as Scala. There are also other testing and mocking frameworks in Java.

为什么你应该听我们的?

Why Should You Listen to Us?

本书总结了我们几十年的经验,包括近十年的测试驱动开发。在此期间,我们在各种项目中使用了 TDD:大型面向消息的企业集成系统,具有由多处理器计算网格支持的交互式 Web 前端;必须在数十千字节内存中运行的微型嵌入式系统;用作业务关键型系统广告的免费游戏;以及高度交互的图形桌面应用程序的后端中间件和网络服务。此外,我们还在世界各地的活动和公司中撰写和教授了这些材料。

This book distills our experiences over a couple of decades, including nearly ten years of test-driven development. During that time, we have used TDD in a wide range of projects: large message-oriented enterprise-integration systems with an interactive web front-end backed by multiprocessor compute grids; tiny embedded systems that must run in tens of kilobytes of memory; free games used as advertising for business-critical systems; and back-end middleware and network services to highly interactive graphical desktop applications. In addition, we’ve written about and taught this material at events and companies across the world.

我们也受益于伦敦 TDD 社区同事的经验。我们在工作中和工作后花了很多时间来挑战和磨练我们的想法。我们很感激有机会与如此活跃(和善于争论)的同事一起工作。

We’ve also benefited from the experience of our colleagues in the TDD community based in London. We’ve spent many hours during and after work having our ideas challenged and honed. We’re grateful for the opportunity to work with such lively (and argumentative) colleagues.

这本书里有什么?

What Is in This Book?

本书分为六个部分:

The book has six parts:

第一部分简介”是对软件开发项目环境中的测试驱动开发、模拟对象和面向对象设计的高级介绍。我们还介绍了本书其余部分中使用的一些测试框架。即使您已经熟悉 TDD,我们仍然建议您阅读第1章和第 2章,因为它们描述了我们的软件开发方法。如果您熟悉 JUnit 和 jMock,您可能希望跳过其余的简介。

Part I, “Introduction,” is a high-level introduction to test-driven development, mock objects, and object-oriented design within the context of a software development project. We also introduce some of the testing frameworks we use in the rest of the book. Even if you’re already familiar with TDD, we stilll recommend reading through Chapters 1 and 2 since they describe our approach to software development. If you’re familiar with JUnit and jMock, you might want to skip the rest of the introduction.

第二部分测试驱动开发过程”描述了 TDD 的过程,展示了如何开始以及如何保持开发进程。我们深入研究了测试驱动方法与面向对象编程之间的关系,展示了这两种技术的原理如何相互支持。最后,我们讨论如何使用外部代码。本部分描述了概念,下一部分将它们付诸实践。

Part II, “The Process of Test-Driven Development,” describes the process of TDD, showing how to get started and how to keep development moving. We dig into the relationship between our test-driven approach and object-oriented programming, showing how the principles of the two techniques support each other. Finally, we discuss how to work with external code. This part describes the concepts, the next part puts them to work.

第三部分一个实际示例”是一个扩展示例,它展示了我们如何以测试驱动的方式开发面向对象应用程序。在此过程中,我们讨论了所做决策的权衡和动机。我们将其写成了一个相当长的示例,因为我们想展示随着代码开始扩展,TDD 的某些功能如何变得更加重要。

Part III, “A Worked Example,” is an extended example that gives a flavor of how we develop an object-oriented application in a test-driven manner. Along the way, we discuss the trade-offs and motivations for the decisions we take. We’ve made this quite a long example, because we want to show how some features of TDD become more significant as the code starts to scale up.

第 IV 部分可持续的测试驱动开发”介绍了一些保持系统可维护性的做法。如今,我们非常小心地保持代码库的整洁和富有表现力,因为多年来我们已经了解到让事情出错的代价。本部分介绍了我们采用的一些做法,并解释了我们为什么要这样做。

Part IV, “Sustainable Test-Driven Development,” describes some practices that keep a system maintainable. We’re very careful these days about keeping a codebase clean and expressive, because we’ve learned over the years the costs of letting things slip. This part describes some of the practices we’ve adopted and explains why we do them.

第五部分高级主题”探讨了 TDD 更难的领域:复杂的测试数据、持久性和并发性。我们将展示如何处理这些问题以及这些问题如何影响代码和测试的设计。

Part V, “Advanced Topics,” looks at areas where TDD is more difficult: complex test data, persistence, and concurrency. We show how we deal with these issues and how this affects the design of the code and tests.

最后,附录包括一些关于 jMock 和 Hamcrest 的支持材料。

Finally, the appendices include some supporting material on jMock and Hamcrest.

本书没有包含什么?

What Is Not in This Book?

这是一本技术书籍。我们忽略了使项目成功的所有其他主题,例如团队组织、需求管理和产品设计。采用增量测试驱动开发方法显然与项目的运行方式密切相关。TDD 可以实现一些新活动,例如频繁交付,但它可能会因组织情况而受到阻碍,例如早期设计冻结或团队利益相关者不沟通。同样,还有很多其他书籍涵盖这些主题。

This is a technical book. We’ve left out all the other topics that make a project succeed, such as team organization, requirements management, and product design. Adopting an incremental test-driven approach to development obviously has a close relationship with how a project is run. TDD enables some new activities, such as frequent delivery, and it can be crippled by organizational circumstances, such as an early design freeze or team stakeholders that don’t communicate. Again, there are plenty of other books to cover these topics.

致谢

Acknowledgments

作者要感谢在本书写作期间提供支持和反馈的所有人。Kent Beck 和 Greg Doench 最初委托了本书的写作,Dmitry Kirsanov 和 Alina Kirsanova(非常耐心地)润色了粗糙的边缘并将其付印。

The authors would like to thank everyone who provided their support and feedback during the writing of this book. Kent Beck and Greg Doench commissioned it in the first place, and Dmitry Kirsanov and Alina Kirsanova (with great patience) polished up the rough edges and turned it into print.

许多人不辞辛劳阅读和审阅草稿,或仅仅提供支持和鼓励,帮助了我们:Romilly Cocking、Jamie Dobson、Michael Feathers、Martin Fowler、Naresh Jain、Pete Keller、Tim Mackinnon、Duncan McGregor、Ivan Moore、Farshad Nayeri、Isaiah Perumalla、David Peterson、Nick Pomfret、JB Rainsberger、James Richardson、Lauren Schmitt、Douglas Squirrel、The Silicon Valley Patterns Group、Vladimir Trofimov、Daniel Wellman 和 Matt Wynne。

A great many people helped us by taking the trouble to read and review drafts, or just providing support and encouragement: Romilly Cocking, Jamie Dobson, Michael Feathers, Martin Fowler, Naresh Jain, Pete Keller, Tim Mackinnon, Duncan McGregor, Ivan Moore, Farshad Nayeri, Isaiah Perumalla, David Peterson, Nick Pomfret, J. B. Rainsberger, James Richardson, Lauren Schmitt, Douglas Squirrel, The Silicon Valley Patterns Group, Vladimir Trofimov, Daniel Wellman, and Matt Wynne.

感谢 Dave Denton、Jonathan “Buck” Rogers 和 Jim Kuo 的建模工作。

Thanks to Dave Denton, Jonathan “Buck” Rogers, and Jim Kuo for modeling duties.

如果没有 Extreme Tuesday Club (XTC),这本书和我们在书中描述的技术就不会存在。XTC 是伦敦为对敏捷、极限编程和测试驱动开发感兴趣的人定期举办的非正式聚会。我们非常感谢所有与我们分享经验、技术、经验教训和回合的人。

This book and the techniques we describe within it would not have existed without the Extreme Tuesday Club (XTC), a regular informal meet-up in London for people interested in agile, extreme programming and test-driven development. We are deeply grateful to all the people with whom we shared experiences, techniques, lessons learned, and rounds.

关于作者

About the Authors

史蒂夫弗里曼

Steve Freeman

Steve Freeman 是专门从事敏捷软件开发的独立顾问 ( http://www.m3p.co.uk )。他与 Nat Pryce 共同获得了 2006 年敏捷联盟 Gordon Pask 奖。他是伦敦 Extreme Tuesday Club 的创始成员,曾担任首届伦敦 XP Day 的主席,并经常在国际会议上组织和演讲。Steve 曾在各种组织工作过,从为 IBM 开发收缩包装软件到为主要研究实验室设计原型。Steve 拥有剑桥大学博士学位以及统计学和音乐学位。Steve 现居英国伦敦。

Steve Freeman is an independent consultant specializing in Agile software development (http://www.m3p.co.uk). He was joint winner, with Nat Pryce, of the 2006 Agile Alliance Gordon Pask award. A founding member of the London Extreme Tuesday Club, he was chair of the first London XP Day and is a frequent organizer and presenter at international conferences. Steve has worked in a wide variety of organizations, from developing shrink-wrap software for IBM to prototyping for major research labs. Steve has a PhD from Cambridge University, and degrees in statistics and music. Steve is based in London, UK.

纳特·普赖斯

Nat Pryce

在帝国理工学院获得博士学位后,Nat Pryce 加入了互联网泡沫,但很快就见证了泡沫的破灭。从那时起,他曾在多个行业担任过程序员、架构师、培训师和顾问,包括体育报道、营销传播、零售、电信和金融。他还参与过学术研究项目,偶尔在大学任教。作为 XP 的早期采用者,他编写或贡献了多个支持 TDD 的开源库,并且是伦敦 XP Day 会议的创始组织者之一。他还定期在国际会议上发言。Nat 现居英国伦敦。

After completing his PhD at Imperial College, Nat Pryce joined the dot-com boom just in time to watch it bust. Since then he has worked as a programmer, architect, trainer, and consultant in a variety of industries, including sports reportage, marketing communications, retail, telecoms, and finance. He has also worked on academic research projects and does occasional university teaching. An early adopter of XP, he has written or contributed to several open source libraries that support TDD and was one of the founding organizers of the London XP Day conference. He also regularly presents at international conferences. Nat is based in London, UK.

第一部分 简介

Part I. Introduction

测试驱动开发 (TDD) 是一个看似简单的概念:在编写代码之前先为代码编写测试。我们之所以说“看似简单”,是因为它改变了测试在开发过程中所扮演的角色,并挑战了我们行业对测试用途的假设。测试不再只是防止用户发现缺陷;相反,它是为了帮助团队了解用户需要的功能,并可靠且可预测地提供这些功能。如果遵循其结论,TDD 将从根本上改变我们开发软件的方式,并且根据我们的经验,它极大地提高了我们构建的系统的质量,特别是它们的可靠性和响应新需求的灵活性。

Test-Driven Development (TDD) is a deceptively simple idea: Write the tests for your code before writing the code itself. We say “deceptively simple” because this transforms the role testing plays in the development process and challenges our industry’s assumptions about what testing is for. Testing is no longer just about keeping defects from the users; instead, it’s about helping the team to understand the features that the users need and to deliver those features reliably and predictably. When followed to its conclusions, TDD radically changes the way we develop software and, in our experience, dramatically improves the quality of the systems we build, in particular their reliability and their flexibility in response to new requirements.

测试驱动开发在“敏捷”软件开发方法中得到广泛应用。它是极限编程 (XP) [Beck99]的核心实践,受到 Crystal Clear [Cockburn04]的推荐,并经常用于 Scrum 项目[Schwaber01]。我们在参与的每个敏捷项目中都使用了 TDD,并在非敏捷项目中发现了它的用途。我们甚至发现它帮助我们在纯研究项目中取得进展,这些项目的动机是探索想法而不是交付功能。

Test-driven development is widely used in “agile” software development approaches. It is a core practice of Extreme Programming (XP) [Beck99], is recommended by Crystal Clear [Cockburn04], and is often used in Scrum projects [Schwaber01]. We’ve used TDD on every agile project we’ve been involved in, and have found uses for it in non-agile projects. We’ve even found that it helps us make progress in pure research projects, where the motivation is to explore ideas rather than deliver features.

第 1 章 测试驱动开发的意义是什么?

Chapter 1. What Is the Point of Test-Driven Development?

一个人必须通过实践来学习;因为尽管你认为你知道,但除非你尝试,否则你无法确定。

One must learn by doing the thing; for though you think you know it, you have no certainty, until you try.

—索福克勒斯

—Sophocles

软件开发作为学习过程

Software Development as a Learning Process

几乎所有软件项目都在尝试做以前没有人做过的事情(或者至少是组织中以前没有人做过的事情)。这些事情可能涉及参与的人员、应用领域、所使用的技术,或者(最有可能)这些的组合。尽管我们尽了最大努力,但除了最常规的项目之外,所有项目都存在意外因素。有趣的项目(可能带来最大收益的项目)通常会带来很多意外。

Almost all software projects are attempting something that nobody has done before (or at least that nobody in the organization has done before). That something may refer to the people involved, the application domain, the technology being used, or (most likely) a combination of these. In spite of the best efforts of our discipline, all but the most routine projects have elements of surprise. Interesting projects—those likely to provide the most benefit—usually have a lot of surprises.

开发人员通常并不完全了解他们所使用的技术。他们必须在完成项目的同时学习组件的工作原理。即使他们很好地理解了这些技术,新的应用程序也可能会迫使他们进入不熟悉的角落。一个结合了许多重要组件(这意味着专业程序员所从事的大部分工作)的系统过于复杂,任何个人都无法理解其所有可能性。

Developers often don’t completely understand the technologies they’re using. They have to learn how the components work whilst completing the project. Even if they have a good understanding of the technologies, new applications can force them into unfamiliar corners. A system that combines many significant components (which means most of what a professional programmer works on) will be too complex for any individual to understand all of its possibilities.

对于客户和最终用户来说,体验更糟糕。构建系统的过程迫使他们比以前更加仔细地审视自己的组织。他们经常不得不协商和编纂迄今为止基于惯例和经验的流程。

For customers and end users, the experience is worse. The process of building a system forces them to look at their organization more closely than they have before. They’re often left to negotiate and codify processes that, until now, have been based on convention and experience.

软件项目的每个参与者都必须在项目进展过程中不断学习。为了使项目取得成功,参与的人员必须齐心协力,了解他们应该实现的目标,并在此过程中发现和解决误解。他们都知道会发生变化,只是不知道会有什么变化。他们需要一个流程来帮助他们应对不确定性,随着经验的增长,预测意料之外的变化

Everyone involved in a software project has to learn as it progresses. For the project to succeed, the people involved have to work together just to understand what they’re supposed to achieve, and to identify and resolve misunderstandings along the way. They all know there will be changes, they just don’t know what changes. They need a process that will help them cope with uncertainty as their experience grows—to anticipate unanticipated changes.

反馈是基本工具

Feedback Is the Fundamental Tool

我们认为,团队可以采取的最佳方法是利用经验反馈来了解系统及其用途,然后将学习到的知识应用到系统中。团队需要重复的活动周期。在每个周期中,它都会添加新功能并获得有关已完成工作的数量和质量的反馈。团队成员将工作划分为时间框,在此期间,他们会分析、设计、实施和部署尽可能多的功能。

We think that the best approach a team can take is to use empirical feedback to learn about the system and its use, and then apply that learning back to the system. A team needs repeated cycles of activity. In each cycle it adds new features and gets feedback about the quantity and quality of the work already done. The team members split the work into time boxes, within which they analyze, design, implement, and deploy as many features as they can.

在每个周期将完成的工作部署到某种环境中至关重要。每次团队部署时,其成员都有机会根据现实检查他们的假设。他们可以衡量他们实际取得的进展,检测和纠正任何错误,并根据他们学到的知识调整当前计划。没有部署,反馈就不完整。

Deploying completed work to some kind of environment at each cycle is critical. Every time a team deploys, its members have an opportunity to check their assumptions against reality. They can measure how much progress they’re really making, detect and correct any errors, and adapt the current plan in response to what they’ve learned. Without deployment, the feedback is not complete.

在我们的工作中,我们将反馈周期应用于开发的每个阶段,将项目组织为一个嵌套循环系统,循环周期从几秒到几个月不等,例如:结对编程、单元测试、验收测试、每日会议、迭代、发布等等。每个循环都会将团队的输出暴露给经验反馈,以便团队可以发现并纠正任何错误或误解。嵌套的反馈循环相互加强;如果差异在内循环中溜走,外循环很有可能会发现它。

In our work, we apply feedback cycles at every level of development, organizing projects as a system of nested loops ranging from seconds to months, such as: pair programming, unit tests, acceptance tests, daily meetings, iterations, releases, and so on. Each loop exposes the team’s output to empirical feedback so that the team can discover and correct any errors or misconceptions. The nested feedback loops reinforce each other; if a discrepancy slips through an inner loop, there is a good chance an outer loop will catch it.

每个反馈循环都涉及系统和开发过程的不同方面。内循环更侧重于技术细节:代码单元的作用是什么,它是否与系统的其余部分集成。外循环更侧重于组织和团队:应用程序是否满足用户的需求,团队是否尽可能高效。

Each feedback loop addresses different aspects of the system and development process. The inner loops are more focused on the technical detail: what a unit of code does, whether it integrates with the rest of the system. The outer loops are more focused on the organization and the team: whether the application serves its users’ needs, whether the team is as effective as it could be.

我们越早获得有关项目任何方面的反馈,就越好。大型组织中的许多团队可以每隔几周发布一次。有些团队每隔几天甚至几小时发布一次,这使他们有更多机会接收和响应来自真实用户的反馈。

The sooner we can get feedback about any aspect of the project, the better. Many teams in large organizations can release every few weeks. Some teams release every few days, or even hours, which gives them an order of magnitude increase in opportunities to receive and respond to feedback from real users.

支持变革的实践

Practices That Support Change

我们发现,如果想要可靠地发展系统并应对总是发生的意外变化,我们需要两个技术基础。首先,我们需要不断测试以捕获回归错误,这样我们就可以添加新功能而不会破坏现有功能。对于任何规模的系统,频繁的手动测试都是不切实际的,因此我们必须尽可能地自动化测试,以降低构建、部署和修改系统版本的成本。

We’ve found that we need two technical foundations if we want to grow a system reliably and to cope with the unanticipated changes that always happen. First, we need constant testing to catch regression errors, so we can add new features without breaking existing ones. For systems of any interesting size, frequent manual testing is just impractical, so we must automate testing as much as we can to reduce the costs of building, deploying, and modifying versions of the system.

其次,我们需要让代码尽可能简单,这样更容易理解和修改。开发人员花在阅读代码上的时间比写代码的时间多得多,所以这就是我们应该优化的地方。1简单需要付出努力,因此我们在处理代码时不断重构 [Fowler99]代码 — 以改进和简化其设计、消除重复并确保其清楚地表达其功能。反馈循环中的测试套件可防止我们在改进(并因此更改)代码时犯下自己的错误。

Second, we need to keep the code as simple as possible, so it’s easier to understand and modify. Developers spend far more time reading code than writing it, so that’s what we should optimize for.1 Simplicity takes effort, so we constantly refactor [Fowler99] our code as we work with it—to improve and simplify its design, to remove duplication, and to ensure that it clearly expresses what it does. The test suites in the feedback loops protect us against our own mistakes as we improve (and therefore change) the code.

1. Begel 和 Simon [Begel08] 的研究表明,微软的新毕业生在第一年的大部分时间里都在阅读代码。

1. Begel and Simon [Begel08] showed that new graduates at Microsoft spend most of their first year just reading code.

问题是,很少有开发人员喜欢测试他们的代码。在许多开发团队中,与添加功能相比,编写自动化测试被视为不是“真正的”工作,而且也很无聊。大多数人在他们觉得没有灵感的工作中表现得并不好。

The catch is that few developers enjoy testing their code. In many development groups, writing automated tests is seen as not “real” work compared to adding features, and boring as well. Most people do not do as well as they should at work they find uninspiring.

测试驱动开发(TDD) 彻底改变了这种情况。我们在编写代码之前编写测试。TDD 将测试变成了一项设计活动,而不是在工作完成后仅仅使用测试来验证工作。我们使用测试来澄清我们想要代码做什么的想法。正如 Kent Beck 向我们描述的那样,“我终于能够将逻辑设计与物理设计区分开来。我一直被告知要这样做,但没有人解释如何做。”我们发现,首先编写测试的努力也能让我们快速获得有关设计理念质量的反馈 - 使代码可供测试通常会使其变得更干净、更模块化。

Test-Driven Development (TDD) turns this situation on its head. We write our tests before we write the code. Instead of just using testing to verify our work after it’s done, TDD turns testing into a design activity. We use the tests to clarify our ideas about what we want the code to do. As Kent Beck described it to us, “I was finally able to separate logical from physical design. I’d always been told to do that but no one ever explained how.” We find that the effort of writing a test first also gives us rapid feedback about the quality of our design ideas—that making code accessible for testing often drives it towards being cleaner and more modular.

如果我们在整个开发过程中编写测试,我们就可以建立一个自动回归测试的安全网,让我们有信心做出改变。

If we write tests all the way through the development process, we can build up a safety net of automated regression tests that give us the confidence to make changes.

“... 你没有什么可失去的,除了你的虫子”

“... you have nothing to lose but your bugs”

图像

我们再怎么强调使用经过全面测试的测试驱动代码是多么自由都不为过。我们发现,我们可以集中精力完成手头的任务,确信自己在做正确的工作,而且实际上很难破坏系统 — 只要我们遵循实践。

We cannot emphasize strongly enough how liberating it is to work on test-driven code that has thorough test coverage. We find that we can concentrate on the task in hand, confident that we’re doing the right work and that it’s actually quite hard to break the system—as long as we follow the practices.

测试驱动开发简介

Test-Driven Development in a Nutshell

TDD 的核心循环是:编写测试;编写一些代码使其运行;重构代码以尽可能简单地实现测试功能。重复。

The cycle at the heart of TDD is: write a test; write some code to get it working; refactor the code to be as simple an implementation of the tested features as possible. Repeat.

图 1.1 基本 TDD 周期

Figure 1.1 The fundamental TDD cycle

图像

在开发系统时,我们使用 TDD 来反馈其实现(“它能用吗?”)和设计(“它结构合理吗?”)的质量。通过先测试再开发,我们发现这种努力能带来双重好处。编写测试:

As we develop the system, we use TDD to give us feedback on the quality of both its implementation (“Does it work?”) and design (“Is it well structured?”). Developing test-first, we find we benefit twice from the effort. Writing tests:

• 让我们明确下一个工作的验收标准——我们必须问自己如何知道我们已经完成了(设计);

• makes us clarify the acceptance criteria for the next piece of work—we have to ask ourselves how we can tell when we’re done (design);

• 鼓励我们编写松散耦合的组件,这样它们就可以轻松地进行单独测试,并在更高层次上组合在一起(设计);

• encourages us to write loosely coupled components, so they can easily be tested in isolation and, at higher levels, combined together (design);

添加代码功能(设计)的可执行描述;以及,

• adds an executable description of what the code does (design); and,

• 添加到完整的回归套件(实施);

• adds to a complete regression suite (implementation);

运行测试时:

whereas running tests:

• 在我们对上下文记忆犹新的时候检测错误(实施);并且,

• detects errors while the context is fresh in our mind (implementation); and,

• 让我们知道我们已经做得足够多了,阻止“镀金”和不必要的功能(设计)。

• lets us know when we’ve done enough, discouraging “gold plating” and unnecessary features (design).

这个反馈循环可以通过 TDD 的黄金法则来总结:

This feedback cycle can be summed up by the Golden Rule of TDD:

测试驱动开发的黄金法则

The Golden Rule of Test-Driven Development

图像

永远不要编写没有经过测试的新功能。

Never write new functionality without a failing test.

更大的图景

The Bigger Picture

通过为应用程序中的类编写单元测试来启动 TDD 流程是一种很诱人的做法。这比完全没有测试要好,并且可以捕获我们都知道但很难避免的那些基本编程错误:栅栏错误、不正确的布尔表达式等。但是,只有单元测试的项目会错过 TDD 流程的关键优势。我们见过一些项目,这些项目的代码质量很高,经过了良好的单元测试,但结果却无法从任何地方调用,或者无法与系统的其余部分集成,因此不得不重写。

It is tempting to start the TDD process by writing unit tests for classes in the application. This is better than having no tests at all and can catch those basic programming errors that we all know but find so hard to avoid: fencepost errors, incorrect boolean expressions, and the like. But a project with only unit tests is missing out on critical benefits of the TDD process. We’ve seen projects with high-quality, well unit-tested code that turned out not to be called from anywhere, or that could not be integrated with the rest of the system and had to be rewritten.

我们如何知道从哪里开始编写代码?更重要的是,我们如何知道何时停止编写代码?黄金法则告诉我们需要做什么:编写失败测试

How do we know where to start writing code? More importantly, how do we know when to stop writing code? The golden rule tells us what we need to do: Write a failing test.

当我们实现一个功能时,我们首先要编写一个验收测试,该测试会测试我们想要构建的功能。如果验收测试失败,则表明系统尚未实现该功能;如果验收测试通过,则我们的工作就完成了。在开发一个功能时,我们会使用其验收测试来指导我们是否确实需要即将编写的代码 — 我们只编写直接相关的代码。在验收测试之下,我们遵循单元级测试/实现/重构循环来开发该功能;整个循环如图 1.2所示。

When we’re implementing a feature, we start by writing an acceptance test, which exercises the functionality we want to build. While it’s failing, an acceptance test demonstrates that the system does not yet implement that feature; when it passes, we’re done. When working on a feature, we use its acceptance test to guide us as to whether we actually need the code we’re about to write—we only write code that’s directly relevant. Underneath the acceptance test, we follow the unit level test/implement/refactor cycle to develop the feature; the whole cycle looks like Figure 1.2.

图 1.2 TDD 中的内反馈环和外反馈环

Figure 1.2 Inner and outer feedback loops in TDD

图像

外部测试循环是可证明进度的衡量标准,不断增加的测试套件可防止我们在更改系统时出现回归失败。验收测试通常需要一段时间才能通过,肯定不止一次签入,因此我们通常会区分正在进行的验收测试(尚未包含在构建中)和已完成功能的验收测试(已包含在构建中并且必须始终通过)。

The outer test loop is a measure of demonstrable progress, and the growing suite of tests protects us against regression failures when we change the system. Acceptance tests often take a while to make pass, certainly more than one check-in episode, so we usually distinguish between acceptance tests we’re working on (which are not yet included in the build) and acceptance tests for the features that have been finished (which are included in the build and must always pass).

内部循环支持开发人员。单元测试帮助我们维护代码质量,编写后应尽快通过。失败的单元测试绝不应提交到源存储库。

The inner loop supports the developers. The unit tests help us maintain the quality of the code and should pass soon after they’ve been written. Failing unit tests should never be committed to the source repository.

端到端测试

Testing End-to-End

只要有可能,验收测试就应该端到端地测试系统,而不直接调用其内部代码。端到端测试只从外部与系统交互:通过用户界面、通过发送消息(好像来自第三方系统)、通过调用其 Web 服务、通过解析报告等。正如我们在第 10 章中讨论的那样,系统的整体行为包括它与外部环境的交互。这通常是最危险和最困难的方面;我们忽视它会带来危险。我们尽量避免只测试系统内部对象的验收测试,除非我们真的需要加速并且已经有一套稳定的端到端测试来提供保障。

Wherever possible, an acceptance test should exercise the system end-to-end without directly calling its internal code. An end-to-end test interacts with the system only from the outside: through its user interface, by sending messages as if from third-party systems, by invoking its web services, by parsing reports, and so on. As we discuss in Chapter 10, the whole behavior of the system includes its interaction with its external environment. This is often the riskiest and most difficult aspect; we ignore it at our peril. We try to avoid acceptance tests that just exercise the internal objects of the system, unless we really need the speed-up and already have a stable set of end-to-end tests to provide cover.

对我们来说,“端到端”不仅仅意味着从外部与系统交互——也许称之为“边到边”测试更好。我们更喜欢让端到端测试同时测试系统及其构建和部署过程。自动构建通常由某人将代码签入源代码存储库触发,它将:签出最新版本;编译和单元测试代码;集成和打包系统;在现实环境中执行类似生产的部署;最后,通过系统的外部访问点测试系统。这听起来很费力(确实如此),但在软件的生命周期内必须反复进行。许多步骤可能很繁琐且容易出错,因此端到端构建周期是自动化的理想选择。您将在第 10 章中看到我们在项目的早期就开始这项工作了。

For us, “end-to-end” means more than just interacting with the system from the outside—that might be better called “edge-to-edge” testing. We prefer to have the end-to-end tests exercise both the system and the process by which it’s built and deployed. An automated build, usually triggered by someone checking code into the source repository, will: check out the latest version; compile and unit-test the code; integrate and package the system; perform a production-like deployment into a realistic environment; and, finally, exercise the system through its external access points. This sounds like a lot of effort (it is), but has to be done anyway repeatedly during the software’s lifetime. Many of the steps might be fiddly and error-prone, so the end-to-end build cycle is an ideal candidate for automation. You’ll see in Chapter 10 how early in a project we get this working.

当所有验收测试都通过时,系统即可部署,因为它们应该给我们足够的信心,让我们相信一切都能正常工作。然而,还有最后一步,即部署到生产环境。在许多组织中,尤其是大型或监管严格的组织中,构建可部署系统只是发布过程的开始。在最终用户最终可以使用新功能之前,其余工作可能涉及不同类型的测试、移交给运营和数据组以及与其他团队的发布协调。发布还可能涉及额外的非技术成本,例如培训、营销或停机对服务协议的影响。结果是发布周期比我们想要的更困难,因此我们必须了解整个技术和组织环境。

A system is deployable when the acceptance tests all pass, because they should give us enough confidence that everything works. There’s still, however, a final step of deploying to production. In many organizations, especially large or heavily regulated ones, building a deployable system is only the start of a release process. The rest, before the new features are finally available to the end users, might involve different kinds of testing, handing over to operations and data groups, and coordinating with other teams’ releases. There may also be additional, nontechnical costs involved with a release, such as training, marketing, or an impact on service agreements for downtime. The result is a more difficult release cycle than we would like, so we have to understand our whole technical and organizational environment.

测试级别

Levels of Testing

我们构建了一个测试层次结构,对应于我们上面描述的一些嵌套反馈循环:

We build a hierarchy of tests that correspond to some of the nested feedback loops we described above:

验收:整个系统是否运转正常?

Acceptance: Does the whole system work?

集成:我们的代码是否可以与我们无法更改的代码兼容?

Integration: Does our code work against code we can’t change?

单元:我们的对象是否做了正确的事情,它们使用起来是否方便?

Unit: Do our objects do the right thing, are they convenient to work with?

在 TDD 领域,我们对于所谓的验收测试的术语有过很多讨论:“功能测试”、“客户测试”、“系统测试”。更糟糕的是,我们的定义通常与专业软件测试人员使用的定义不同。重要的是要明确我们的意图。我们使用“验收测试”来帮助我们与领域专家一起理解并就下一步要构建的内容达成一致。我们还使用它们来确保在继续开发时不会破坏任何现有功能。

There’s been a lot of discussion in the TDD world over the terminology for what we’re calling acceptance tests: “functional tests,” “customer tests,” “system tests.” Worse, our definitions are often not the same as those used by professional software testers. The important thing is to be clear about our intentions. We use “acceptance tests” to help us, with the domain experts, understand and agree on what we are going to build next. We also use them to make sure that we haven’t broken any existing features as we continue developing.

我们对验收测试“角色”的首选实现是编写端到端测试,正如我们刚才提到的,应该尽可能地端到端;我们的偏见常常导致我们互换使用这些术语,尽管在某些情况下,验收测试可能不是端到端的。

Our preferred implementation of the “role” of acceptance testing is to write end-to-end tests which, as we just noted, should be as end-to-end as possible; our bias often leads us to use these terms interchangeably although, in some cases, acceptance tests might not be end-to-end.

我们使用术语集成测试来指代检查我们的某些代码如何与我们无法更改的团队外部代码协同工作的测试。它可能是一个公共框架,例如持久性映射器,也可能是我们组织内另一个团队的库。区别在于集成测试确保我们在第三方代码上构建的任何抽象都能按预期工作。在小型系统中,例如我们在第三部分中开发的系统,验收测试可能就足够了。然而,在大多数专业开发中,我们希望集成测试能够帮助解决外部包的配置问题,并比(不可避免地)较慢的验收测试提供更快的反馈。

We use the term integration tests to refer to the tests that check how some of our code works with code from outside the team that we can’t change. It might be a public framework, such as a persistence mapper, or a library from another team within our organization. The distinction is that integration tests make sure that any abstractions we build over third-party code work as we expect. In a small system, such as the one we develop in Part III, acceptance tests might be enough. In most professional development, however, we’ll want integration tests to help tease out configuration issues with the external packages, and to give quicker feedback than the (inevitably) slower acceptance tests.

我们不会过多地介绍验收测试和集成测试的技术,因为这两者都取决于所涉及的技术,甚至取决于组织的文化。您将在第三部分中看到一些示例,我们希望这些示例能够让您了解验收测试的动机,并展示它们如何融入开发周期。但是,单元测试技术特定于一种编程风格,因此在所有采用该方法的系统中都很常见 - 在我们的例子中是面向对象的。

We won’t write much more about techniques for acceptance and integration testing, since both depend on the technologies involved and even the culture of the organization. You’ll see some examples in Part III which we hope give a sense of the motivation for acceptance tests and show how they fit in the development cycle. Unit testing techniques, however, are specific to a style of programming, and so are common across all systems that take that approach—in our case, are object-oriented.

外部和内部质量

External and Internal Quality

还有另一种方式来查看测试可以告诉我们有关系统的信息。我们可以区分外部质量和内部质量:外部质量是系统满足客户和用户需求的程度(它是否功能齐全、可靠、可用、响应迅速等),而内部质量是系统满足开发人员和管理员需求的程度(它是否易于理解、易于更改等)。每个人都能理解外部质量的意义;它通常是构建合同的一部分。内部质量同样重要,但通常更难实现。内部质量使我们能够应对持续和意外的变化,正如我们在本章开头所看到的,这是使用软件的一个事实。维护内部质量的目的是让我们能够安全且可预测地修改系统的行为,因为它将更改导致大规模返工的风险降至最低。

There’s another way of looking at what the tests can tell us about a system. We can make a distinction between external and internal quality: External quality is how well the system meets the needs of its customers and users (is it functional, reliable, available, responsive, etc.), and internal quality is how well it meets the needs of its developers and administrators (is it easy to understand, easy to change, etc.). Everyone can understand the point of external quality; it’s usually part of the contract to build. The case for internal quality is equally important but is often harder to make. Internal quality is what lets us cope with continual and unanticipated change which, as we saw at the beginning of this chapter, is a fact of working with software. The point of maintaining internal quality is to allow us to modify the system’s behavior safely and predictably, because it minimizes the risk that a change will force major rework.

运行端到端测试可以告诉我们系统的外部质量,编写端到端测试可以告诉我们我们(整个团队)对领域的了解程度,但端到端测试无法告诉我们代码编写得有多好。编写单元测试可以给我们提供大量有关代码质量的反馈,运行单元测试可以告诉我们没有破坏任何类 — 但同样,单元测试不能让我们有足够的信心相信整个系统可以正常工作。集成测试介于两者之间,如图1.3所示。

Running end-to-end tests tells us about the external quality of our system, and writing them tells us something about how well we (the whole team) understand the domain, but end-to-end tests don’t tell us how well we’ve written the code. Writing unit tests gives us a lot of feedback about the quality of our code, and running them tells us that we haven’t broken any classes—but, again, unit tests don’t give us enough confidence that the system as a whole works. Integration tests fall somewhere in the middle, as in Figure 1.3.

图 1.3 测试反馈

Figure 1.3 Feedback from tests

图像

彻底的单元测试有助于我们提高内部质量,因为要进行测试,单元必须被构造为在测试装置中在系统外部运行。对象的单元测试需要创建对象、提供其依赖项、与其交互并检查其是否按预期运行。因此,要使类易于进行单元测试,该类必须具有易于替换的显式依赖项和易于调用和验证的明确职责。用软件工程术语来说,这意味着代码必须松散耦合高度内聚- 换句话说,设计良好。

Thorough unit testing helps us improve the internal quality because, to be tested, a unit has to be structured to run outside the system in a test fixture. A unit test for an object needs to create the object, provide its dependencies, interact with it, and check that it behaved as expected. So, for a class to be easy to unit-test, the class must have explicit dependencies that can easily be substituted and clear responsibilities that can easily be invoked and verified. In software engineering terms, that means that the code must be loosely coupled and highly cohesive—in other words, well-designed.

当我们犯错时——例如,当一个类与系统的遥远部分紧密耦合、具有隐式依赖关系或具有太多或不明确的职责时——我们会发现单元测试很难编写或理解,因此首先编写测试可以为我们的设计提供有价值的即时反馈。和所有人一样,当我们的代码使测试变得困难时,我们很容易不去编写测试,但我们会努力抵制这种想法。我们利用这些困难作为调查测试难以编写的原因的机会,并重构代码以改进其结构。我们称之为“倾听测试”,我们将在第 20 章中介绍一些常见的模式。

When we’ve got this wrong—when a class, for example, is tightly coupled to distant parts of the system, has implicit dependencies, or has too many or unclear responsibilities—we find unit tests difficult to write or understand, so writing a test first gives us valuable, immediate feedback about our design. Like everyone, we’re tempted not to write tests when our code makes it difficult, but we try to resist. We use such difficulties as an opportunity to investigate why the test is hard to write and refactor the code to improve its structure. We call this “listening to the tests,” and we’ll work through some common patterns in Chapter 20.

第 2 章 使用对象进行测试驱动开发

Chapter 2. Test-Driven Development with Objects

音乐是音符之间的空间。

Music is the space between the notes.

—克劳德·德彪西

—Claude Debussy

对象网络

A Web of Objects

面向对象设计更注重对象之间的通信,而不是对象本身。正如 Alan Kay [Kay98]所写:

Object-oriented design focuses more on the communication between objects than on the objects themselves. As Alan Kay [Kay98] wrote:

主要思想是“消息传递”[...] 构建伟大且可扩展的系统的关键在于设计其模块如何通信,而不是它们的内部属性和行为应该是什么。

The big idea is “messaging” [...] The key in making great and growable systems is much more to design how its modules communicate rather than what their internal properties and behaviors should be.

对象通过消息进行通信:它接收来自其他对象的消息,并通过向其他对象发送消息以及可能向原始发送者返回值或异常来做出反应。对象具有处理其理解的每种消息类型的方法,并且在大多数情况下,封装了一些用于协调与其他对象通信的内部状态。

An object communicates by messages: It receives messages from other objects and reacts by sending messages to other objects as well as, perhaps, returning a value or exception to the original sender. An object has a method of handling every type of message that it understands and, in most cases, encapsulates some internal state that it uses to coordinate its communication with other objects.

面向对象系统是一个协作对象的网络。系统是通过创建对象并将它们连接在一起以便它们可以相互发送消息来构建的。系统的行为是对象组成的一种新兴属性——对象的选择及其连接方式(图 2.1)。

An object-oriented system is a web of collaborating objects. A system is built by creating objects and plugging them together so that they can send messages to one another. The behavior of the system is an emergent property of the composition of the objects—the choice of objects and how they are connected (Figure 2.1).

图 2.1 对象网络

Figure 2.1 A web of objects

图像

这样,我们就可以通过改变系统对象的组成(添加和删除实例、将不同的组合组合在一起)来改变系统的行为,而不必编写过程代码。我们为管理这种组合而编写的代码是对对象网络行为方式的声明性定义。改变系统的行为更容易,因为我们可以专注于我们希望它做什么,而不是如何做

This lets us change the behavior of the system by changing the composition of its objects—adding and removing instances, plugging different combinations together—rather than writing procedural code. The code we write to manage this composition is a declarative definition of the how the web of objects will behave. It’s easier to change the system’s behavior because we can focus on what we want it to do, not how.

值和对象

Values and Objects

在设计系统时,重要的是要区分对不变数量或测量进行建模的,以及具有身份、可能随时间改变状态和对计算过程进行建模的对象。在我们大多数人使用的面向对象语言,令人混淆的是,这两个概念都是由相同的语言结构实现的:类。

When designing a system, it’s important to distinguish between values that model unchanging quantities or measurements, and objects that have an identity, might change state over time, and model computational processes. In the object-oriented languages that most of us use, the confusion is that both concepts are implemented by the same language construct: classes.

是模拟固定数量的不可变实例。它们没有单独的身份,因此如果两个值实例具有相同的状态,则它们实际上是相同的。这意味着比较两个值的身份是没有意义的;这样做可能会导致一些微妙的错误——想想比较两个副本的不同方式new Integer(999)。这就是为什么我们被教导在 Java 中使用string1.equals(string2)而不是string1 == string2

Values are immutable instances that model fixed quantities. They have no individual identity, so two value instances are effectively the same if they have the same state. This means that it makes no sense to compare the identity of two values; doing so can cause some subtle bugs—think of the different ways of comparing two copies of new Integer(999). That’s why we’re taught to use string1.equals(string2) in Java rather than string1 == string2.

另一方面,对象使用可变状态来模拟其随时间的行为。即使两个相同类型的对象现在具有完全相同的状态,它们也具有不同的身份,因为如果它们将来收到不同的消息,它们的状态可能会发生变化。

Objects, on the other hand, use mutable state to model their behavior over time. Two objects of the same type have separate identities even if they have exactly the same state now, because their states can diverge if they receive different messages in the future.

实际上,这意味着我们将系统分为两个“世界”:值(按功能处理)和对象(实现系统的状态行为)。在第三部分中,您将看到我们的编码风格如何根据我们所处的世界而变化。

In practice, this means that we split our system into two “worlds”: values, which are treated functionally, and objects, which implement the stateful behavior of the system. In Part III, you’ll see how our coding style varies depending on which world we’re working in.

在本书中,我们将使用术语“对象”仅指具有身份、状态和处理的实例,而不是值。似乎没有另一个不包含其他含义的公认术语(例如实体过程)。

In this book, we will use the term object to refer only to instances with identity, state, and processing—not values. There doesn’t appear to be another accepted term that isn’t overloaded with other meanings (such as entity and process).

关注消息

Follow the Messages

只有当我们的对象设计为易于插入时,我们才能从这种高级声明式方法中受益。实际上,这意味着它们遵循通用的通信模式,并且它们之间的依赖关系是明确的。通信模式是一组规则,用于控制一组对象如何相互通信:它们扮演的角色、它们可以发送什么消息以及何时发送等等。在 Java 等语言中,我们用(抽象)接口而不是(具体)类来识别对象角色——尽管接口并没有定义我们需要说的一切。

We can benefit from this high-level, declarative approach only if our objects are designed to be easily pluggable. In practice, this means that they follow common communication patterns and that the dependencies between them are made explicit. A communication pattern is a set of rules that govern how a group of objects talk to each other: the roles they play, what messages they can send and when, and so on. In languages like Java, we identify object roles with (abstract) interfaces, rather than (concrete) classes—although interfaces don’t define everything we need to say.

我们认为,领域模型存在于这些通信模式中,因为它们赋予了对象之间可能存在的关系的意义。从动态通信结构的角度来思考系统,是一种重大的思维转变,不同于我们大多数人在接触对象时学到的静态分类。领域模型甚至不明显可见,因为通信模式并没有在我们使用的编程语言中明确表示出来。我们希望在这本书中展示测试和模拟对象如何帮助我们更清楚地了解对象之间的通信。

In our view, the domain model is in these communication patterns, because they are what gives meaning to the universe of possible relationships between the objects. Thinking of a system in terms of its dynamic, communication structure is a significant mental shift from the static classification that most of us learn when being introduced to objects. The domain model isn’t even obviously visible because the communication patterns are not explicitly represented in the programming languages we get to work with. We hope to show, in this book, how tests and mock objects help us see the communication between our objects more clearly.

这是一个关于如何通过关注对象之间的交流来指导设计的小例子。

Here’s a small example of how focusing on the communication between objects guides design.

在视频游戏中,游戏中的对象可能包括:角色,例如玩家和敌人;场景,玩家飞越的场景;障碍物,玩家可能撞上去的障碍物;以及效果,例如爆炸和烟雾。随着游戏的进行,还有脚本在幕后生成对象。

In a video game, the objects in play might include: actors, such as the player and the enemies; scenery, which the player flies over; obstacles, which the player can crash into; and effects, such as explosions and smoke. There are also scripts spawning objects behind the scenes as the game progresses.

从玩家的角度来看,这是对游戏对象的良好分类,因为它支持玩家在玩游戏时(从外部与游戏交互时)需要做出的决定。然而,对于游戏实施者来说,这不是一个有用的分类。游戏引擎必须显示可见的对象,告诉动画对象时间的流逝,检测物理对象之间的碰撞,并将有关物理对象碰撞时该做什么的决定委托给碰撞解析器

This is a good classification of the game objects from the players’ point of view because it supports the decisions they need to make when playing the game—when interacting with the game from outside. This is not, however, a useful classification for the implementers of the game. The game engine has to display objects that are visible, tell objects that are animated about the passing of time, detect collisions between objects that are physical, and delegate decisions about what to do when physical objects collide to collision resolvers.

如图 2.2所示,两个视图(一个来自游戏引擎,另一个来自游戏中对象的实现)并不相同。例如,障碍物是可见的物理的,而脚本是碰撞解析器动画的,但不可见。游戏中的对象根据不同的情况扮演不同的角色取决于引擎当时需要它们做什么。静态分类和动态通信之间的这种不匹配意味着我们不太可能为游戏对象提出一个整洁的类层次结构,以满足引擎的需求。

As you can see in Figure 2.2, the two views, one from the game engine and one from the implementation of the in-play objects, are not the same. An Obstacle, for example, is Visible and Physical, while a Script is a Collision Resolver and Animated but not Visible. The objects in the game play different roles depending on what the engine needs from them at the time. This mismatch between static classification and dynamic communication means that we’re unlikely to come up with a tidy class hierarchy for the game objects that will also suit the needs of the engine.

图 2.2 视频游戏中的角色和对象

Figure 2.2 Roles and objects in a video game

图像

类层次结构最好代表应用程序的一个维度,提供一种在对象之间共享实现细节的机制;例如,我们可能有一个基类来实现基于帧的动画的常见功能。最坏的情况是,我们已经看到太多代码库(包括我们自己的代码库)因使用一种机制来表示多个概念而遭受复杂性和重复性。

At best, a class hierarchy represents one dimension of an application, providing a mechanism for sharing implementation details between objects; for example, we might have a base class to implement the common features of frame-based animation. At worst, we’ve seen too many codebases (including our own) that suffer complexity and duplication from using one mechanism to represent multiple concepts.

告诉,不要询问

Tell, Don’t Ask

我们让对象相互发送消息,那么它们会说什么呢?我们的经验是,调用对象应该根据其邻居扮演的角色描述它想要什么,然后让被调用对象决定如何实现这一点。这通常被称为“告诉,不要询问”风格,或更正式地说,迪米特法则。对象仅根据其内部保存的信息或触发消息附带的信息做出决定;它们避免导航到其他对象来使事情发生。始终遵循这种风格,可以产生更灵活的代码,因为可以轻松交换扮演相同角色的对象。调用者看不到它们的内部结构或角色接口背后的其余系统的结构。

We have objects sending each other messages, so what do they say? Our experience is that the calling object should describe what it wants in terms of the role that its neighbor plays, and let the called object decide how to make that happen. This is commonly known as the “Tell, Don’t Ask” style or, more formally, the Law of Demeter. Objects make their decisions based only on the information they hold internally or that which came with the triggering message; they avoid navigating to other objects to make things happen. Followed consistently, this style produces more flexible code because it’s easy to swap objects that play the same role. The caller sees nothing of their internal structure or the structure of the rest of the system behind the role interface.

如果我们不遵循这种风格,我们最终会得到所谓的“火车失事”代码,其中一系列 getter 像火车上的车厢一样串联在一起。这是我们在互联网上发现的一个案例:

When we don’t follow the style, we can end up with what’s known as “train wreck” code, where a series of getters is chained together like the carriages in a train. Here’s one case we found on the Internet:

((编辑保存自定义器) master.getModelisable()

.getDockablePanel()

.getCustomizer())

.getSaveItem().setEnabled(Boolean.FALSE.booleanValue());

((EditSaveCustomizer) master.getModelisable()

.getDockablePanel()

.getCustomizer())

.getSaveItem().setEnabled(Boolean.FALSE.booleanValue());

经过一番思考,我们意识到这个片段的意思

After some head scratching, we realized what this fragment was meant to say:

主.允许保存自定义内容();

master.allowSavingOfCustomisations();

这将所有实现细节都封装在单个调用中。客户端master不再需要了解链中的类型。我们降低了设计变更可能对代码库的远程部分造成影响的风险。

This wraps all that implementation detail up behind a single call. The client of master no longer needs to know anything about the types in the chain. We’ve reduced the risk that a design change might cause ripples in remote parts of the codebase.

除了隐藏信息之外,“告诉,不要询问”还有一个更微妙的好处。它迫使我们明确说明对象之间的交互,而不是将它们隐含在 getter 链中。上面的简短版本更清楚地说明了它的用途,而不仅仅是它是如何实现的。

As well as hiding information, there’s a more subtle benefit from “Tell, Don’t Ask.” It forces us to make explicit and so name the interactions between objects, rather than leaving them implicit in the chain of getters. The shorter version above is much clearer about what it’s for, not just how it happens to be implemented.

但有时要问

But Sometimes Ask

当然,我们不会“讲述”一切;1我们在从值和集合中获取信息时,或者在使用工厂创建新对象时“询问”。有时,我们在搜索或过滤时也会询问对象的状态,但我们仍然希望保持表达能力并避免“火车失事”。

Of course we don’t “tell” everything;1 we “ask” when getting information from values and collections, or when using a factory to create new objects. Occasionally, we also ask objects about their state when searching or filtering, but we still want to maintain expressiveness and avoid “train wrecks.”

1.虽然这是一项有趣的练习,但可以尝试一下,拓展您的技巧。

1. Although that’s an interesting exercise to try, to stretch your technique.

例如(继续打比方),如果我们天真地想将预留座位分散到整列火车上,我们可能会从以下方式开始:

For example (to continue with the metaphor), if we naively wanted to spread reserved seats out across the whole of a train, we might start with something like:

公共类火车 {

私有最终列表 <Carriage> 车厢[...]

私有 int percentReservedBarrier = 70;



公共无效 reserveSeats(ReservationRequest 请求) {

对于(Carriage 车厢:车厢) {

如果(carriage.getSeats().getPercentReserved() < percentReservedBarrier) {

request.reserveSeatsIn(carriage);

返回;

}

}

request.cannotFindSeats();

}

}

public class Train {

private final List<Carriage> carriages [...]

private int percentReservedBarrier = 70;



public void reserveSeats(ReservationRequest request) {

for (Carriage carriage : carriages) {

if (carriage.getSeats().getPercentReserved() < percentReservedBarrier) {

request.reserveSeatsIn(carriage);

return;

}

}

request.cannotFindSeats();

}

}

我们不应该为了实现这一点而暴露内部结构Carriage,尤其是因为一列火车里可能有不同类型的车厢。相反,我们应该问我们真正想要回答的问题,而不是要求提供信息来帮助我们自己找出答案:

We shouldn’t expose the internal structure of Carriage to implement this, not least because there may be different types of carriages within a train. Instead, we should ask the question we really want answered, instead of asking for the information to help us figure out the answer ourselves:

public void reserveSeats(ReservationRequest request) {

for (Carriage carriage: carriages) {

if (carriage.hasSeatsAvailableWithin (percentReservedBarrier) ) {

request.reserveSeatsIn(carriage);

return;

}

}

request.cannotFindSeats();

}

public void reserveSeats(ReservationRequest request) {

for (Carriage carriage : carriages) {

if (carriage.hasSeatsAvailableWithin(percentReservedBarrier)) {

request.reserveSeatsIn(carriage);

return;

}

}

request.cannotFindSeats();

}

添加查询方法将行为移动到最合适的对象,赋予其解释性名称,并使其更易于测试。

Adding a query method moves the behavior to the most appropriate object, gives it an explanatory name, and makes it easier to test.

我们尽量少用对象查询(而不是值查询),因为查询可能会让信息从对象中“泄露”,从而使系统变得有点僵化。至少,我们会特意编写查询来描述调用对象的意图,而不仅仅是实现。

We try to be sparing with queries on objects (as opposed to values) because they can allow information to “leak” out of the object, making the system a little bit more rigid. At a minimum, we make a point of writing queries that describe the intention of the calling object, not just the implementation.

对协作对象进行单元测试

Unit-Testing the Collaborating Objects

我们似乎把自己逼入了绝境。我们坚持让焦点对象相互发送命令,并且不公开任何查询其状态的方法,因此看起来我们在单元测试中没有任何可用的东西可以断言。例如,在图 2.4中,带圆圈的对象在被调用时会向其三个邻居中的一个或多个发送消息。我们如何才能测试它是否正确地执行了这些操作,而不会公开其任何内部状态?

We appear to have painted ourselves into a corner. We’re insisting on focused objects that send commands to each other and don’t expose any way to query their state, so it looks like we have nothing available to assert in a unit test. For example, in Figure 2.4, the circled object will send messages to one or more of its three neighbors when invoked. How can we test that it does so correctly without exposing any of its internal state?

图 2.4 单独对对象进行单元测试

Figure 2.4 Unit-testing an object in isolation

图像

一种选择是将测试中的目标对象的邻居替换为替代对象或模拟对象,如图 2.5所示。我们可以指定我们期望目标对象如何与其模拟邻居进行通信以触发事件;我们将这些规范称为期望。在测试期间,模拟对象断言它们已经按预期被调用;它们还实现了使其余测试工作所需的任何存根行为。

One option is to replace the target object’s neighbors in a test with substitutes, or mock objects, as in Figure 2.5. We can specify how we expect the target object to communicate with its mock neighbors for a triggering event; we call these specifications expectations. During the test, the mock objects assert that they have been called as expected; they also implement any stubbed behavior needed to make the rest of the test work.

图 2.5 使用模拟对象测试对象

Figure 2.5 Testing an object with mock objects

图像

有了这个基础架构,我们就可以改变 TDD 的方法。图 2.5表示我们只是试图测试目标对象,并且我们已经知道它的邻居是什么样子。然而,实际上,当我们编写单元测试时,这些协作者不需要存在。我们可以使用测试来帮助我们梳理出对象所需的支持角色(定义为 Java 接口),并在开发系统其余部分时填写实际实现。我们称之为接口发现;当我们在第 12 章AuctionEventListener中提取时,您将看到一个例子。

With this infrastructure in place, we can change the way we approach TDD. Figure 2.5 implies that we’re just trying to test the target object and that we already know what its neighbors look like. In practice, however, those collaborators don’t need to exist when we’re writing a unit test. We can use the test to help us tease out the supporting roles our object needs, defined as Java interfaces, and fill in real implementations as we develop the rest of the system. We call this interface discovery; you’ll see an example when we extract an AuctionEventListener in Chapter 12.

使用 Mock 对象支持 TDD

Support for TDD with Mock Objects

为了支持这种测试驱动编程风格,我们需要创建邻近对象的模拟实例,定义对如何调用它们的期望,然后检查它们,并实现我们通过测试所需的任何存根行为。实际上,带有模拟对象的测试的运行时结构通常如图 2.6所示。

To support this style of test-driven programming, we need to create mock instances of the neighboring objects, define expectations on how they’re called and then check them, and implement any stub behavior we need to get through the test. In practice, the runtime structure of a test with mock objects usually looks like Figure 2.6.

图 2.6 使用模拟对象测试对象

Figure 2.6 Testing an object with mock objects

图像

我们用嘲弄这个词2表示保存测试上下文、创建模拟对象以及管理测试期望和存根的对象。我们将在第三部分中展示这种做法,因此我们在这里只介绍基础知识。测试的基本结构是:

We use the term mockery2 for the object that holds the context of a test, creates mock objects, and manages expectations and stubbing for the test. We’ll show the practice throughout Part III, so we’ll just touch on the basics here. The essential structure of a test is:

2.这是我们一时兴起采用的 Ivan Moore 的双关语。

2. This is a pun by Ivan Moore that we adopted in a fit of whimsy.

• 创建任何所需的模拟对象。

• Create any required mock objects.

• 创建任何真实对象,包括目标对象。

• Create any real objects, including the target object.

• 指定您希望目标对象如何调用模拟对象。

• Specify how you expect the mock objects to be called by the target object.

• 调用目标对象上的触发方法。

• Call the triggering method(s) on the target object.

• 断言所有结果值都是有效的,并且所有预期的调用均已完成。

• Assert that any resulting values are valid and that all the expected calls have been made.

单元测试明确了目标对象与其环境之间的关系。它创建集群中的所有对象,并对目标对象与其协作者之间的交互做出断言。我们可以手动编写此基础结构,或者,如今,可以使用多种语言中提供的多个模拟对象框架之一。正如我们在本书中反复强调的那样,重点是明确每个测试的目的,区分测试的功能、支持基础结构和对象结构。

The unit test makes explicit the relationship between the target object and its environment. It creates all the objects in the cluster and makes assertions about the interactions between the target object and its collaborators. We can code this infrastructure by hand or, these days, use one of the multiple mock object frameworks that are available in many languages. The important point, as we stress repeatedly throughout this book, is to make clear the intention of every test, distinguishing between the tested functionality, the supporting infrastructure, and the object structure.

第 3 章 工具介绍

Chapter 3. An Introduction to the Tools

人是使用工具的动物。没有工具,人就一无是处;有了工具,人就拥有了一切。

Man is a tool-using animal. Without tools he is nothing, with tools he is all.

—托马斯·卡莱尔

—Thomas Carlyle

如果你以前听过这个,就阻止我

Stop Me If You’ve Heard This One Before

本书介绍的是使用测试指导面向对象软件开发的技术,而不是具体技术。然而,为了演示这些技术的实际应用,我们必须选择一些技术作为示例代码。在本书的其余部分,我们将使用 Java,以及 JUnit 4、Hamcrest 和 jMock2 框架。如果您使用的是其他语言,我们希望我们已经足够清楚,以便您可以在您的环境中应用这些想法。

This book is about the techniques of using tests to guide the development of object-oriented software, not about specific technologies. To demonstrate the techniques in action, however, we’ve had to pick some technologies for our example code. For the rest of the book we’re going to use Java, with the JUnit 4, Hamcrest, and jMock2 frameworks. If you’re using something else, we hope we’ve been clear enough so that you can apply these ideas in your environment.

本章我们简要描述了这三个框架的编程接口,足以帮助您理解本书其余部分的代码示例。如果您已经知道如何使用它们,则可以跳过本章。

In this chapter we briefly describe the programming interfaces for these three frameworks, just enough to help you make sense of the code examples in the rest of the book. If you already know how to use them, you can skip this chapter.

JUnit 4 简介

A Minimal Introduction to JUnit 4

我们使用 JUnit 4(撰写本文时为 4.6 版本)作为我们的 Java 测试框架。1本质上,JUnit 使用反射来遍历类的结构并运行它在该类中找到的代表测试的任何内容。例如,这是一个测试,它执行一个Catalog管理对象集合的类Entry

We use JUnit 4 (version 4.6 at the time of writing) as our Java test framework.1 In essence, JUnit uses reflection to walk the structure of a class and run whatever it can find in that class that represents a test. For example, here’s a test that exercises a Catalog class which manages a collection of Entry objects:

1. JUnit 与许多 Java IDE 捆绑在一起,可在www.junit.org上获取。

1. JUnit is bundled with many Java IDEs and is available at www.junit.org.

公共类 CatalogTest {

私有最终 Catalog catalog = new Catalog();

@Test public void containsAnAddedEntry() {

Entry entry = new Entry("fish", "chips");

catalog.add(entry);

assertTrue(catalog.contains(entry));

}

@Test public void indexesEntriesByName() {

Entry entry = new Entry("fish", "chips");

catalog.add(entry);

assertEquals(entry, catalog.entryFor("fish"));

assertNull(catalog.entryFor("missing name"));

}

}

public class CatalogTest {

private final Catalog catalog = new Catalog();

@Test public void containsAnAddedEntry() {

Entry entry = new Entry("fish", "chips");

catalog.add(entry);

assertTrue(catalog.contains(entry));

}

@Test public void indexesEntriesByName() {

Entry entry = new Entry("fish", "chips");

catalog.add(entry);

assertEquals(entry, catalog.entryFor("fish"));

assertNull(catalog.entryFor("missing name"));

}

}

测试用例

Test Cases

JUnit 将任何标有 注释的方法@Test视为测试用例;测试方法既不能有返回值也不能有参数。在本例中,CatalogTest定义了两个测试,分别称为containsAnAddedEntry()indexesEntriesByName()

JUnit treats any method annotated with @Test as a test case; test methods must have neither a return value nor parameters. In this case, CatalogTest defines two tests, called containsAnAddedEntry() and indexesEntriesByName().

要运行测试,JUnit 会创建测试类的新实例并调用相关测试方法。每次创建一个新测试对象可确保测试彼此隔离,因为测试对象的字段在每次测试之前都会被替换。这意味着测试可以自由更改任何测试对象字段的内容。

To run a test, JUnit creates a new instance of the test class and calls the relevant test method. Creating a new test object each time ensures that the tests are isolated from each other, because the test object’s fields are replaced before each test. This means that a test is free to change the contents of any of the test object fields.

NUnit 的行为与 JUnit 不同

NUnit Behaves Differently from JUnit

图像

那些在 .Net 中工作的人应该注意,NUnit 对所有测试方法重用了相同的测试对象实例,因此任何可能发生变化的值都必须在[Setup][TearDown]方法中重置(如果它们是字段)或者设为测试方法的本地值。

Those working in .Net should note that NUnit reuses the same instance of the test object for all the test methods, so any values that might change must either be reset in [Setup] and [TearDown] methods (if they’re fields) or made local to the test method.

断言

Assertions

JUnit 测试调用被测试的对象,然后对结果做出断言,通常使用 JUnit 定义的断言方法,当失败时会生成有用的错误消息。

A JUnit test invokes the object under test and then makes assertions about the results, usually using assertion methods defined by JUnit which generate useful error messages when they fail.

CatalogTest例如,使用了 JUnit 的三个断言:assertTrue()断言表达式为真;assertNull()断言对象引用为空;assertEquals()断言两个值相等。当它失败时,assertEquals()报告所比较的预期值和实际值。

CatalogTest, for example, uses three of JUnit’s assertions: assertTrue() asserts that an expression is true; assertNull() asserts that an object reference is null; and assertEquals() asserts that two values are equal. When it fails, assertEquals() reports the expected and actual values that were compared.

期待异常

Expecting Exceptions

@Test注释支持可选参数expected,用于声明测试用例应抛出异常。如果测试用例未抛出异常或抛出其他类型的异常,则测试失败。

The @Test annotation supports an optional parameter expected that declares that the test case should throw an exception. The test fails if it does not throw an exception or if it throws an exception of a different type.

例如,以下测试检查当添加两个具有相同名称的条目时是否Catalog会引发:IllegalArgumentException

For example, the following test checks that a Catalog throws an IllegalArgumentException when two entries are added with the same name:

@Test(预期=IllegalArgumentException.class )

public void couldnotAddTwoEntriesWithTheSameName() {

catalog.add(new Entry("鱼", "薯片");

catalog.add(new Entry("鱼", "豌豆");

}

@Test(expected=IllegalArgumentException.class)

public void cannotAddTwoEntriesWithTheSameName() {

catalog.add(new Entry("fish", "chips");

catalog.add(new Entry("fish", "peas");

}

测试治具

Test Fixtures

测试装置是测试开始时存在的固定状态。测试装置可确保测试可重复 - 每次运行测试时,它都以相同的状态开始,因此应该产生相同的结果。装置可以在测试运行前设置,并在测试完成后拆除。

A test fixture is the fixed state that exists at the start of a test. A test fixture ensures that a test is repeatable—every time a test is run it starts in the same state so it should produce the same results. A fixture may be set up before the test runs and torn down after it has finished.

JUnit 测试的 Fixture 由定义测试的类管理,并存储在对象的字段中。同一类中定义的所有测试都以相同的 Fixture 开始,并且可能会在运行时修改该 Fixture。对于CatalogTest,Fixture 是Catalog其字段中保存的空对象catalog

The fixture for a JUnit test is managed by the class that defines the test and is stored in the object’s fields. All tests defined in the same class start with an identical fixture and may modify that fixture as they run. For CatalogTest, the fixture is the empty Catalog object held in its catalog field.

装置通常由字段初始化程序设置。它也可以由测试类的构造函数或实例初始化程序块设置。JUnit 还允许您使用注释来识别设置和拆除装置的方法。JUnit 将@Before在运行测试之前运行所有带有注释的方法,以设置装置,并@After在运行测试之后运行带有注释的方法,以拆除装置。许多 JUnit 测试不需要明确拆除装置,因为让 JVM 垃圾收集在设置时创建的任何对象就足够了。

The fixture is usually set up by field initializers. It can also be set up by the constructor of the test class or instance initializer blocks. JUnit also lets you identify methods that set up and tear down the fixture with annotations. JUnit will run all methods annotated with @Before before running the tests, to set up the fixture, and those annotated by @After after it has run the test, to tear down the fixture. Many JUnit tests do not need to explicitly tear down the fixture because it is enough to let the JVM garbage collect any objects created when it was set up.

例如,所有测试都使用相同的条目CatalogTest初始化catalog。此通用初始化可以移至字段初始化器和@Before方法中:

For example, all the tests in CatalogTest initialize the catalog with the same entry. This common initialization can be moved into a field initializer and @Before method:

公共类 CatalogTest {

最终 Catalog catalog = new Catalog();

最终 Entry entry = new Entry("fish", "chips");



@Before public void fillTheCatalog() {

catalog.add(entry);

}



@Test public void containsAnAddedEntry() {

assertTrue(catalog.contains(entry));

}



@Test public void indexesEntriesByName() {

assertEquals(equalTo(entry), catalog.entryFor("fish"));

assertNull(catalog.entryFor("missing name"));

}



@Test(expected=IllegalArgumentException.class)

public void couldnotAddTwoEntriesWithTheSameName() {

catalog.add(new Entry("fish", "peas");

}

}

public class CatalogTest {

final Catalog catalog = new Catalog();

final Entry entry = new Entry("fish", "chips");



@Before public void fillTheCatalog() {

catalog.add(entry);

}



@Test public void containsAnAddedEntry() {

assertTrue(catalog.contains(entry));

}



@Test public void indexesEntriesByName() {

assertEquals(equalTo(entry), catalog.entryFor("fish"));

assertNull(catalog.entryFor("missing name"));

}



@Test(expected=IllegalArgumentException.class)

public void cannotAddTwoEntriesWithTheSameName() {

catalog.add(new Entry("fish", "peas");

}

}

测试运行器

Test Runners

JUnit 反射类以查找测试并运行这些测试的方式由测试运行器控制。可以使用@RunWith 注解。2 JUnit 提供了一个小型的测试运行器库。例如,Parameterized测试运行器允许您编写数据驱动的测试,其中相同的测试方法针对静态方法返回的许多不同数据值运行。

The way JUnit reflects on a class to find tests and then runs those tests is controlled by a test runner. The runner used for a class can be configured with the @RunWith annotation.2 JUnit provides a small library of test runners. For example, the Parameterized test runner lets you write data-driven tests in which the same test methods are run for many different data values returned from a static method.

2.到发布时,JUnit 还将对Rule字段进行注释,以支持可以“拦截”测试运行生命周期的对象。

2. By the time of publication, JUnit will also have a Rule annotation for fields to support objects that can “intercept” the lifecycle of a test run.

正如我们将在下面看到的,jMock 库使用自定义测试运行器在测试结束时(测试装置被拆除之前)自动验证模拟对象。

As we’ll see below, the jMock library uses a custom test runner to automatically verify mock objects at the end of the test, before the test fixture is torn down.

Hamcrest Matchers 和 assertThat()

Hamcrest Matchers and assertThat()

Hamcrest 是一个用于编写声明式匹配条件的框架。虽然 Hamcrest 本身并不是一个测试框架,但它被多个测试框架使用,包括 JUnit、jMock 和 WindowLicker,我们在第三部分的示例中使用了它们。

Hamcrest is a framework for writing declarative match criteria. While not a testing framework itself, Hamcrest is used by several testing frameworks, including JUnit, jMock, and WindowLicker, which we use in the example in Part III.

Hamcrest匹配器报告给定对象是否符合某些条件,可以描述其条件,并可以描述对象不符合其条件的原因。例如,此代码为包含给定子字符串的字符串创建匹配器,并使用它们进行一些断言:

A Hamcrest matcher reports whether a given object matches some criteria, can describe its criteria, and can describe why an object does not meet its criteria. For example, this code creates matchers for strings that contain a given substring and uses them to make some assertions:

String s = "是的,我们今天没有香蕉";



Matcher<String> containsBananas = new StringContains("bananas");

Matcher<String> containsMangoes = new StringContains("mangoes");



assertTrue(containsBananas.matches(s));

assertFalse(containsMangoes.matches(s));

String s = "yes we have no bananas today";



Matcher<String> containsBananas = new StringContains("bananas");

Matcher<String> containsMangoes = new StringContains("mangoes");



assertTrue(containsBananas.matches(s));

assertFalse(containsMangoes.matches(s));

匹配器通常不会直接实例化。相反,Hamcrest 为其所有匹配器提供静态工厂方法,以使创建匹配器的代码更具可读性。例如:

Matchers are not usually instantiated directly. Instead, Hamcrest provides static factory methods for all of its matchers to make the code that creates matchers more readable. For example:

断言True(包含String("香蕉"。匹配(s));

断言False(包含String("芒果"。匹配(s));

assertTrue(containsString("bananas").matches(s));

assertFalse(containsString("mangoes").matches(s));

然而在实践中,我们将匹配器与 JUnit 结合使用assertThat(),它使用匹配器的自描述特性来清楚地说明断言失败时到底出了什么问题。3我们可以把断言重写如下:

In practice, however, we use matchers in combination with JUnit’s assertThat(), which uses matcher’s self-describing features to make clear exactly what went wrong when an assertion fails.3 We can rewrite the assertions as:

3 .该assertThat()方法是在JUnit 4.5中引入的。

3. The assertThat() method was introduced in JUnit 4.5.

断言(s,containsString("香蕉"));

断言(s,not(containsString("芒果"));

assertThat(s, containsString("bananas"));

assertThat(s, not(containsString("mangoes"));

第二个断言演示了 Hamcrest 最有用的功能之一:通过组合现有匹配器来定义新标准。该not()方法是一个工厂函数,它创建一个匹配器,该匹配器会反转传递给它的任何匹配器的含义。匹配器的设计使得当它们组合在一起时,代码和失败消息都是不言自明的。例如,如果我们将第二个断言更改为失败:

The second assertion demonstrates one of Hamcrest’s most useful features: defining new criteria by combining existing matchers. The not() method is a factory function that creates a matcher that reverses the sense of any matcher passed to it. Matchers are designed so that when they’re combined, both the code and the failure messages are self-explanatory. For example, if we change the second assertion to fail:

断言(s,不(containsString("bananas"));

assertThat(s, not(containsString("bananas"));

失败报告为:

the failure report is:

java.lang.AssertionError:

预期:不是包含“香蕉”的字符串,

得到:“是的,我们没有香蕉”

java.lang.AssertionError:

Expected: not a string containing "bananas"

got: "Yes, we have no bananas"

我们不需要编写代码来明确检查条件并生成信息性错误消息,而是可以传递匹配器表达式assertThat()并让其完成工作。

Instead of writing code to explicitly check a condition and to generate an informative error message, we can pass a matcher expression to assertThat() and let it do the work.

Hamcrest 也是用户可扩展的。如果我们需要检查特定条件,我们可以通过实现接口和适当命名的工厂方法来编写一个新的匹配器,结果将与现有的匹配器表达式无缝结合。我们在附录 BMatcher中描述了如何编写自定义 Hamcrest 匹配器。

Hamcrest is also user-extensible. If we need to check a specific condition, we can write a new matcher by implementing the Matcher interface and an appropriately-named factory method, and the result will combine seamlessly with the existing matcher expressions. We describe how to write custom Hamcrest matchers in Appendix B.

jMock2:模拟对象

jMock2: Mock Objects

jMock2 可插入 JUnit(和其他测试框架),为第 2 章中介绍的模拟对象测试样式提供支持。jMock 可动态创建模拟对象,因此您不必编写要模拟的类型的实现。它还提供了一个高级 API,用于指定被测对象应如何调用与其交互的模拟对象,以及模拟对象将如何响应。

jMock2 plugs into JUnit (and other test frameworks) providing support for the mock objects testing style introduced in Chapter 2. jMock creates mock objects dynamically, so you don’t have to write your own implementations of the types you want to mock. It also provides a high-level API for specifying how the object under test should invoke the mock objects it interacts with, and how the mock objects will behave in response.

理解 jMock

Understanding jMock

图像

jMock 旨在使期望描述尽可能清晰。为此,我们使用了一些不寻常的 Java 编码实践,乍一看可能令人惊讶。jMock 的设计灵感来自本书中提出的想法,并以多年实际项目经验为后盾。如果您不明白这些示例的含义,请参阅附录 Awww.jmock.org上的更多描述。我们(当然)认为,在您有机会完成一些示例之前,值得暂时搁置您的判断。

jMock is designed to make the expectation descriptions as clear as possible. We used some unusual Java coding practices to do so, which can appear surprising at first. jMock’s design was motivated by the ideas presented in this book, backed by many years of experience in real projects. If the examples don’t make sense to you, there’s more description in Appendix A and at www.jmock.org. We (of course) believe that it’s worth suspending your judgment until you’ve had a chance to work through some of the examples.

jMock API 的核心概念是mockerymock 对象expectation。mockery 表示被测对象及其相邻对象的上下文;mock 对象在测试运行时代表被测对象的真实邻居;expectation 描述被测对象在测试期间应如何调用其邻居。

The core concepts of the jMock API are the mockery, mock objects, and expectations. A mockery represents the context of the object under test, its neighboring objects; mock objects stand in for the real neighbors of the object under test while the test runs; and expectations describe how the object under test should invoke its neighbors during the test.

一个示例将展示它们如何组合在一起。此测试断言AuctionMessageTranslator将解析给定的消息文本以生成auctionClosed()事件。现在,只需集中精力于结构;测试将在第 12 章的上下文中再次出现。

An example will show how these fit together. This test asserts that an AuctionMessageTranslator will parse a given message text to generate an auctionClosed() event. For now, just concentrate on the structure; the test will turn up again in context in Chapter 12.

@RunWith(JMock.class)图像

公共类 AuctionMessageTranslatorTest {

私有最终 Mockery 上下文 = 新 JUnit4Mockery();图像

私有最终 AuctionEventListener 监听器 =

context.mock(AuctionEventListener.class);图像

私有最终 AuctionMessageTranslator 翻译器 =

新 AuctionMessageTranslator(监听器); 图像



@Test 公共 void

notifiesAuctionClosedWhenCloseMessageReceived() {

消息消息 = 新消息();

消息.setBody("SOLVersion: 1.1; 事件: CLOSE;");图像



上下文.checking(新期望() {{ 图像

oneOf(监听器).auctionClosed(); 图像

}});



翻译器.processMessage(UNUSED_CHAT, 消息); 图像

} 图像

}

@RunWith(JMock.class)

public class AuctionMessageTranslatorTest {

private final Mockery context = new JUnit4Mockery();

private final AuctionEventListener listener =

context.mock(AuctionEventListener.class);

private final AuctionMessageTranslator translator =

new AuctionMessageTranslator(listener);



@Test public void

notifiesAuctionClosedWhenCloseMessageReceived() {

Message message = new Message();

message.setBody("SOLVersion: 1.1; Event: CLOSE;");



context.checking(new Expectations() {{

oneOf(listener).auctionClosed();

}});



translator.processMessage(UNUSED_CHAT, message);

}

}

图像@RunWith(JMock.class)注释告诉 JUnit 使用 jMock 测试运行器,它会在测试结束时自动调用模拟来检查所有模拟对象是否已按预期调用。

The @RunWith(JMock.class) annotation tells JUnit to use the jMock test runner, which automatically calls the mockery at the end of the test to check that all mock objects have been invoked as expected.

图像测试创建Mockery。由于这是一个 JUnit 4 测试,因此它会创建一个,它JUnit4Mockery会抛出正确类型的异常以向 JUnit 4 报告测试失败。按照惯例,jMock 测试将模拟保存在名为 的字段中context,因为它代表了测试对象的上下文。

The test creates the Mockery. Since this is a JUnit 4 test, it creates a JUnit4Mockery which throws the right type of exception to report test failures to JUnit 4. By convention, jMock tests hold the mockery in a field named context, because it represents the context of the object under test.

图像测试使用模拟来创建一个模拟AuctionEventListener,它将在测试期间代替真实的监听器实现。

The test uses the mockery to create a mock AuctionEventListener that will stand in for a real listener implementation during this test.

图像测试实例化被测对象,并将AuctionMessageTranslator模拟侦听器传递给其构造函数。AuctionMessageTranslator不区分真实侦听器和模拟侦听器:它通过AuctionEventListener接口进行通信,而不关心该接口是如何实现的。

The test instantiates the object under test, an AuctionMessageTranslator, passing the mock listener to its constructor. The AuctionMessageTranslator does not distinguish between a real and a mock listener: It communicates through the AuctionEventListener interface and does not care how that interface is implemented.

图像测试设置了将在测试中使用的更多对象。

The test sets up further objects that will be used in the test.

图像然后,测试通过定义期望块来告诉模拟翻译器在测试期间应如何调用其邻居。我们用于执行此操作的 Java 语法很晦涩,因此如果您现在能忍受我们,我们将在附录 A中更详细地解释它。

The test then tells the mockery how the translator should invoke its neighbors during the test by defining a block of expectations. The Java syntax we use to do this is obscure, so if you can bear with us for now we explain it in more detail in Appendix A.

图像这是测试中最重要的一行,也是我们的期望。它表示,在操作过程中,我们希望监听器的auctionClosed()方法只被调用一次。我们对成功的定义是,翻译器将通知其监听器auctionClosed()在接收到原始消息时,就会通知事件已经发生Close

This is the significant line in the test, its one expectation. It says that, during the action, we expect the listener’s auctionClosed() method to be called exactly once. Our definition of success is that the translator will notify its listener that an auctionClosed() event has happened whenever it receives a raw Close message.

图像这是对测试对象的调用,即触发我们要测试的行为的外部事件。它将原始Close消息传递给转换器,测试表明,该消息应该使转换器auctionClosed()在侦听器上调用一次。模拟将在测试运行时检查模拟对象是否按预期调用,如果意外调用,则测试立即失败。

This is the call to the object under test, the outside event that triggers the behavior we want to test. It passes a raw Close message to the translator which, the test says, should make the translator call auctionClosed() once on the listener. The mockery will check that the mock objects are invoked as expected while the test runs and fail the test immediately if they are invoked unexpectedly.

图像请注意,测试不需要任何断言。这在模拟对象测试中很常见。

Note that the test does not require any assertions. This is quite common in mock object tests.

期望

Expectations

上面的例子指定了一个非常简单的期望。jMock 的期望 API 非常具有表现力。它允许您精确地指定:

The example above specifies one very simple expectation. jMock’s expectation API is very expressive. It lets you precisely specify:

• 预期调用的最小次数和最大次数;

• The minimum and maximum number of times an invocation is expected;

• 是否预期调用(如果没有收到调用,则测试应该失败)或仅仅允许调用发生(如果没有收到调用,则测试应该通过);

• Whether an invocation is expected (the test should fail if it is not received) or merely allowed to happen (the test should pass if it is not received);

• 参数值,要么是字面上给出的,要么是由 Hamcrest 匹配器约束的;

• The parameter values, either given literally or constrained by Hamcrest matchers;

• 相对于其他期望的排序约束;以及

• The ordering constraints with respect to other expectations; and,

• 调用方法时应该发生什么—返回一个值、抛出一个异常或者其他任何行为。

• What should happen when the method is invoked—a value to return, an exception to throw, or any other behavior.

期望块旨在从周围的测试代码中脱颖而出,从而明显区分描述如何调用相邻对象的代码和实际调用对象并测试结果的代码。期望块内的代码充当描述期望的小型声明性语言;我们将在“构建更高级的编程”(第65页)中回顾这个想法。

An expectation block is designed to stand out from the test code that surrounds it, making an obvious separation between the code that describes how neighboring objects should be invoked and the code that actually invokes objects and tests the results. The code within an expectation block acts as a little declarative language that describes the expectations; we’ll return to this idea in “Building Up to Higher-Level Programming” (page 65).

jMock API 还有很多内容,本章就不一一介绍了;我们将在本书的其余部分通过示例描述更多功能,附录 A中提供了摘要。然而,真正重要的不是我们碰巧想到的实现,而是其底层概念和动机。我们将尽力将它们讲清楚。

There’s more to the jMock API which we don’t have space for in this chapter; we’ll describe more of its features in examples in the rest of the book, and there’s a summary in Appendix A. What really matters, however, is not the implementation we happened to come up with, but its underlying concepts and motivations. We will do our best to make them clear.

第二部分 测试驱动开发的过程

Part II. The Process of Test-Driven Development

到目前为止,我们已经对增量式测试驱动开发的概念和动机进行了高层次的介绍。在本书的其余部分,我们将介绍实际操作细节,让其真正发挥作用。

So far we’ve presented a high-level introduction to the concept of, and motivation for, incremental test-driven development. In the rest of the book, we’ll fill in the practical details that actually make it work.

在本部分中,我们将介绍定义我们方法的概念。这些概念可以归结为两个核心原则:持续增量开发和富有表现力的代码。

In this part we introduce the concepts that define our approach. These boil down to two core principles: continuous incremental development and expressive code.

第 4 章 启动测试驱动周期

Chapter 4. Kick-Starting the Test-Driven Cycle

我们应该学会不要等待灵感才开始做事。行动总是会产生灵感。灵感很少会引发行动。

We should be taught not to wait for inspiration to start a thing. Action always generates inspiration. Inspiration seldom generates action.

—弗兰克·蒂博尔特

—Frank Tibolt

介绍

Introduction

我们在第 1 章中描述的 TDD 流程假设我们只需将新功能的测试插入现有基础架构即可扩展系统。但是在拥有此基础架构之前,第一个功能该怎么办?作为验收测试,它必须端到端运行,以便向我们提供有关系统外部接口的反馈,这意味着我们必须实现整个自动化构建、部署和测试周期。在我们看到第一个测试失败之前,还有很多工作要做。

The TDD process we described in Chapter 1 assumes that we can grow the system by just slotting the tests for new features into an existing infrastructure. But what about the very first feature, before we have this infrastructure? As an acceptance test, it must run end-to-end to give us the feedback we need about the system’s external interfaces, which means we must have implemented a whole automated build, deploy, and test cycle. This is a lot of work to do before we can even see our first test fail.

从项目一开始就进行部署和测试,迫使团队了解他们的系统如何适应这个世界。它可以清除“未知的未知”技术和组织风险,以便及时解决这些风险。尝试部署还可以帮助团队了解他们需要与谁联络,例如系统管理员或外部供应商,并开始建立这些关系。

Deploying and testing right from the start of a project forces the team to understand how their system fits into the world. It flushes out the “unknown unknown” technical and organizational risks so they can be addressed while there’s still time. Attempting to deploy also helps the team understand who they need to liaise with, such as system administrators or external vendors, and start to build those relationships.

在一个不存在的系统上开始“构建、部署和测试”听起来很奇怪,但我们认为这是必不可少的。将其留到以后的风险实在太高了。我们曾看到项目在经过数月的开发后被取消,因为他们无法可靠地部署他们的系统。我们曾看到系统被丢弃,因为新功能需要数月的手动回归测试,即使这样,错误率也太高了。与往常一样,我们将反馈视为一种基本工具,我们希望尽早知道我们是否朝着正确的方向前进。这样,一旦我们进行了第一次测试,后续测试的编写速度就会快得多。

Starting with “build, deploy, and test” on a nonexistent system sounds odd, but we think it’s essential. The risks of leaving it to later are just too high. We have seen projects canceled after months of development because they could not reliably deploy their system. We have seen systems discarded because new features required months of manual regression testing and even then the error rates were too high. As always, we view feedback as a fundamental tool, and we want to know as early as possible whether we’re moving in the right direction. Then, once we have our first test in place, subsequent tests will be much quicker to write.

首先,测试行走骨架

First, Test a Walking Skeleton

编写并通过第一个验收测试的难题在于,很难同时构建工具和测试功能。其中一个的更改会破坏另一个的进展,并且当架构、测试和生产代码都在移动时,追踪故障会很棘手。不稳定的开发环境的症状之一是,当出现故障时,没有明显的第一要务。

The quandary in writing and passing the first acceptance test is that it’s hard to build both the tooling and the feature it’s testing at the same time. Changes in one disrupt any progress made with the other, and tracking down failures is tricky when the architecture, the tests, and the production code are all moving. One of the symptoms of an unstable development environment is that there’s no obvious first place to look when something fails.

我们可以通过将“首个功能悖论”拆分为两个较小的问题来解决它。首先,研究如何构建、部署和测试“行走骨架”,然后使用该基础架构为第一个有意义的功能编写验收测试。之后,系统其余部分的测试驱动开发将准备就绪。

We can cut through this “first-feature paradox” by splitting it into two smaller problems. First, work out how to build, deploy, and test a “walking skeleton,” then use that infrastructure to write the acceptance tests for the first meaningful feature. After that, everything will be in place for test-driven development of the rest of the system.

“行走骨架”是实际功能最薄弱部分的实现,我们可以自动构建、部署和端到端测试它[Cockburn04]。它应该包含足够的自动化、主要组件和通信机制,以使我们能够开始开发第一个功能。我们将骨架的应用程序功能保持得非常简单,使其显而易见且不引人注目,让我们可以专注于基础架构。例如,对于数据库支持的 Web 应用程序,骨架将显示一个带有数据库字段的平面网页。在第 10 章中,我们将展示一个示例,该示例在用户界面中显示单个值并仅向服务器发送握手消息。

A “walking skeleton” is an implementation of the thinnest possible slice of real functionality that we can automatically build, deploy, and test end-to-end [Cockburn04]. It should include just enough of the automation, the major components, and communication mechanisms to allow us to start working on the first feature. We keep the skeleton’s application functionality so simple that it’s obvious and uninteresting, leaving us free to concentrate on the infrastructure. For example, for a database-backed web application, a skeleton would show a flat web page with fields from the database. In Chapter 10, we’ll show an example that displays a single value in the user interface and sends just a handshake message to the server.

还需要注意的是,“端到端”中的“端”不仅指系统,也指流程。我们希望测试从头开始,构建可部署的系统,将其部署到类似生产的环境中,然后通过已部署的系统运行测试。将部署步骤纳入测试流程至关重要,原因有二。首先,这是一种容易出错的活动,不应手动完成,因此我们希望在真正部署时,我们的脚本已经得到彻底测试。我们反复学到的一个教训是,没有什么比尝试自动化更能迫使我们理解流程了。其次,这通常是开发团队与组织的其他部分发生冲突并必须了解其运作方式的时刻。如果建立一个数据库需要六周时间和四个签名,我们希望现在就知道,而不是交付前两周。

It’s also important to realize that the “end” in “end-to-end” refers to the process, as well as the system. We want our test to start from scratch, build a deployable system, deploy it into a production-like environment, and then run the tests through the deployed system. Including the deployment step in the testing process is critical for two reasons. First, this is the sort of error-prone activity that should not be done by hand, so we want our scripts to have been thoroughly exercised by the time we have to deploy for real. One lesson that we’ve learned repeatedly is that nothing forces us to understand a process better than trying to automate it. Second, this is often the moment where the development team bumps into the rest of the organization and has to learn how it operates. If it’s going to take six weeks and four signatures to set up a database, we want to know now, not two weeks before delivery.

当然,在实践中,真正的端到端测试可能很难实现,因此我们必须从实现我们目前对真实系统将做什么及其环境的理解的基础设施开始。然而,我们要记住,这只是权宜之计,是在我们完成工作之前的临时补丁,在我们的测试真正端到端运行之前,未知的风险仍然存在。我们的拍卖狙击手示例(第 III 部分)的一个弱点是测试针对虚拟服务器,而非真实网站。在上线前的某个时间点,我们必须针对 Southabee 的在线进行测试;我们越早进行测试,就越容易应对出现的任何意外情况。

In practice, of course, real end-to-end testing may be so hard to achieve that we have to start with infrastructure that implements our current understanding of what the real system will do and what its environment is. We keep in mind, however, that this is a stop-gap, a temporary patch until we can finish the job, and that unknown risks remain until our tests really run end-to-end. One of the weaknesses of our Auction Sniper example (Part III) is that the tests run against a dummy server, not the real site. At some point before going live, we would have had to test against Southabee’s On-Line; the earlier we can do that, the easier it will be for us to respond to any surprises that turn up.

在构建“可行骨架”时,我们专注于结构,而不必过多担心清理测试以使其具有优美的表达能力。可行骨架及其支持基础设施可以帮助我们弄清楚如何开始测试驱动开发。这只是迈向完整的端到端验收测试解决方案的第一步。当我们为第一个功能编写测试时,我们需要“编写您想要读取的测试”(第42页),以确保它能够清晰地表达系统的行为。

Whilst building the “walking skeleton,” we concentrate on the structure and don’t worry too much about cleaning up the test to be beautifully expressive. The walking skeleton and its supporting infrastructure are there to help us work out how to start test-driven development. It’s only the first step toward a complete end-to-end acceptance-testing solution. When we write the test for the first feature, then we need to “write the test you want to read” (page 42) to make sure that it’s a clear expression of the behavior of the system.

决定行走骨骼的形状

Deciding the Shape of the Walking Skeleton

开发“可行骨架”是我们开始选择应用程序的高级结构的时刻。如果不了解整体结构,我们就无法自动化构建、部署和测试周期我们目前不需要太多细节,只需要大致了解支持第一个计划版本所需的主要系统组件以及它们将如何通信。我们的经验法则是,我们应该能够在几分钟内在白板上绘制“可行骨架”的设计。

The development of a “walking skeleton” is the moment when we start to make choices about the high-level structure of our application. We can’t automate the build, deploy, and test cycle without some idea of the overall structure. We don’t need much detail yet, just a broad-brush picture of what major system components will be needed to support the first planned release and how they will communicate. Our rule of thumb is that we should be able to draw the design for the “walking skeleton” in a few minutes on a whiteboard.

世界地图

Mappa Mundi

图像

我们发现,维护系统结构的公开图纸(例如,如图 4.1所示,贴在团队工作区域的墙上)有助于团队在编写代码时保持方向。

We find that maintaining a public drawing of the structure of the system, for example on the wall in the team’s work area as in Figure 4.1, helps the team stay oriented when working on the code.

图 4.1 团队工作区墙上绘制的粗略架构图

Figure 4.1 A broad-brush architecture diagram drawn on the wall of a team’s work area

图像

为了设计这个初始结构,我们必须对系统的目的有所了解,否则整个过程可能会变得毫无意义。我们需要从高层次上了解客户的需求,包括功能性和非功能性需求,以指导我们的选择。这项准备工作是项目章程的一部分,我们必须将其放在本书的范围之外。

To design this initial structure, we have to have some understanding of the purpose of the system, otherwise the whole exercise risks being meaningless. We need a high-level view of the client’s requirements, both functional and nonfunctional, to guide our choices. This preparatory work is part of the chartering of the project, which we must leave as outside the scope of this book.

“行走骨架” 的目的在于利用第一个测试的编写来绘制项目的背景,帮助团队规划出解决方案的概况——在编写任何代码之前必须做出的基本决策;图 4.2显示了我们在图 1.2中绘制的 TDD 流程如何适应这一背景。

The point of the “walking skeleton” is to use the writing of the first test to draw out the context of the project, to help the team map out the landscape of their solution—the essential decisions that they must take before they can write any code; Figure 4.2 shows how the TDD process we drew in Figure 1.2 fits into this context.

图4.2 第一次测试的背景

Figure 4.2 The context of the first test

图像

请不要将此与在敏捷开发社区中名声不佳的“预先进行大型设计”(BDUF)相混淆。在开始编码之前,我们不会尝试将整个设计细化到类和算法。我们现在的任何想法都可能是错误的,因此我们更愿意在系统发展过程中发现这些细节。我们正在做出尽可能少的决定来启动 TDD 周期,以便我们能够开始从实际反馈中学习和改进。

Please don’t confuse this with doing “Big Design Up Front” (BDUF) which has such a bad reputation in the Agile Development community. We’re not trying to elaborate the whole design down to classes and algorithms before we start coding. Any ideas we have now are likely to be wrong, so we prefer to discover those details as we grow the system. We’re making the smallest number of decisions we can to kick-start the TDD cycle, to allow us to start learning and improving from real feedback.

建立反馈来源

Build Sources of Feedback

我们无法保证我们针对应用程序设计做出的决定或这些决定所基于的假设是正确的。我们尽了最大努力,但我们唯一可以依靠的是通过在我们的流程中建立反馈来尽快验证它们。我们为实现“行走骨架”而构建的工具是为了支持这一学习过程。当然,这些工具也不会是完美的,我们希望随着我们了解它们对团队的支持程度,我们会逐步改进它们。

We have no guarantees that the decisions we’ve taken about the design of our application, or the assumptions on which they’re based, are right. We do the best we can, but the only thing we can rely on is validating them as soon as possible by building feedback into our process. The tools we build to implement the “walking skeleton” are there to support this learning process. Of course, these tools too will not be perfect, and we expect we will improve them incrementally as we learn how well they support the team.

我们理想的情况是团队定期向实际生产系统发布产品,如图4.3所示。这样系统的利益相关者就可以对系统满足他们需求的程度做出反应,同时让我们判断其实施情况。

Our ideal situation is where the team releases regularly to a real production system, as in Figure 4.3. This allows the system’s stakeholders to respond to how well the system meets their needs, at the same time allowing us to judge its implementation.

图4.3 需求反馈

Figure 4.3 Requirements feedback

图像

我们利用构建和测试的自动化来获得系统质量的反馈,例如我们能否轻松剪切版本并进行部署、设计是否有效以及代码是否优秀。自动化部署有助于我们频繁向真实用户发布,这让我们可以反馈我们对该领域的了解程度,以及在实践中看到系统是否改变了客户的优先事项。

We use the automation of building and testing to give us feedback on qualities of the system, such as how easily we can cut a version and deploy, how well the design works, and how good the code is. The automated deployment helps us release frequently to real users, which gives us feedback on how well we have understood the domain and whether seeing the system in practice has changed our customer’s priorities.

最大的好处是,我们能够根据所学知识做出更改,因为编写所有内容时都以测试为先,这意味着我们将拥有一套全面的回归测试。当然,没有完美的测试,但在实践中,我们发现,一套完善的测试套件可以让我们安全地进行重大更改。

The great benefit is that we will be able to make changes in response to whatever we learn, because writing everything test-first means that we will have a thorough set of regression tests. No tests are perfect, of course, but in practice we’ve found that a substantial test suite allows us to make major changes safely.

尽早暴露不确定性

Expose Uncertainty Early

所有这些努力意味着,考虑到“行走骨架”几乎什么都不做,团队经常会对让其工作所需的时间感到惊讶。这是因为第一步涉及建立大量基础设施,并提出(和回答)许多棘手的问题。随着团队对其需求和目标环境的了解越来越多,实现最初几个功能的时间将变得不可预测。对于一个新团队来说,学习如何合作的社交压力将使情况更加复杂。

All this effort means that teams are frequently surprised by the time it takes to get a “walking skeleton” working, considering that it does hardly anything. That’s because this first step involves establishing a lot of infrastructure and asking (and answering) many awkward questions. The time to implement the first few features will be unpredictable as the team discovers more about its requirements and target environment. For a new team, this will be compounded by the social stresses of learning how to work together.

同事 Fred Tingey 曾观察到,增量开发可能会让不习惯它的团队和管理层感到不安,因为它会在项目开始时就增加压力。后期集成的项目开始时很平静,但通常在项目结束时变得困难,因为团队第一次尝试将系统整合在一起。后期集成是不可预测的,因为团队必须在有限的时间和预算内组装大量活动部件以修复任何故障。结果是经验丰富的利益相关者对增量项目开始时的不稳定性反应不佳,因为他们预计项目结束时的情况会更糟。

Fred Tingey, a colleague, once observed that incremental development can be disconcerting for teams and management who aren’t used to it because it front-loads the stress in a project. Projects with late integration start calmly but generally turn difficult towards the end as the team tries to pull the system together for the first time. Late integration is unpredictable because the team has to assemble a great many moving parts with limited time and budget to fix any failures. The result is that experienced stakeholders react badly to the instability at the start of an incremental project because they expect that the end of the project will be much worse.

我们的经验是,运行良好的增量开发则朝着相反的方向发展。它一开始并不稳定,但在实现了一些功能并建立了项目自动化之后,它就进入了常规状态。随着项目接近交付,最终结果应该是功能稳定地生产,也许在首次发布之前会有一阵活动。所有平凡但脆弱的任务,如部署和升级,都将实现自动化,以便它们“正常工作”。对比看起来更像图 4.4

Our experience is that a well-run incremental development runs in the opposite direction. It starts unsettled but then, after a few features have been implemented and the project automation has been built up, settles in to a routine. As a project approaches delivery, the end-game should be a steady production of functionality, perhaps with a burst of activity before the first release. All the mundane but brittle tasks, such as deployment and upgrades, will have been automated so that they “just work.” The contrast looks rather like Figure 4.4.

图 4.4 先测试和后测试项目中明显的不确定性

Figure 4.4 Visible uncertainty in test-first and test-later projects

图像

测试驱动开发的这个方面,像其他方面一样,可能看起来违反直觉,但我们一直认为花足够的时间来构建和自动化系统的基本功能(或至少是初步功能)是值得的。当然,我们不想花整个项目来建立一个完美的“行走骨架”,所以我们将自己限制在白板级别的决策上,并保留在必要时改变主意的权利。但最重要的是要有方向感和具体的实现来测试我们的假设。

This aspect of test-driven development, like others, may appear counterintuitive, but we’ve always found it worth taking enough time to structure and automate the basics of the system—or at least a first cut. Of course, we don’t want to spend the whole project setting up a perfect “walking skeleton,” so we limit ourselves to whiteboard-level decisions and reserve the right to change our mind when we have to. But the most important thing is to have a sense of direction and a concrete implementation to test our assumptions.

当项目早期还有时间、预算和善意去解决这些问题时,“行走的骨架”就会将问题清除出去。

A “walking skeleton” will flush out issues early in the project when there’s still time, budget, and goodwill to address them.

第 5 章 维护测试驱动周期

Chapter 5. Maintaining the Test-Driven Cycle

每天你都会取得进步。每一步都可能硕果累累。然而,在你面前的是一条不断延伸、不断上升、不断改善的道路。你知道你永远无法到达旅程的终点​​。但这非但没有让人气馁,反而增加了攀登的喜悦和荣耀。

Every day you may make progress. Every step may be fruitful. Yet there will stretch out before you an ever-lengthening, ever-ascending, ever-improving path. You know you will never get to the end of the journey. But this, so far from discouraging, only adds to the joy and glory of the climb.

—温斯顿·丘吉尔

—Winston Churchill

介绍

Introduction

一旦启动了 TDD 流程,我们就需要确保它顺利运行。在本章中,我们将展示启动后的 TDD 流程如何运行。本书的其余部分将详细探讨如何确保它顺利运行 — 如何在构建系统时编写测试,如何使用测试来获取有关内部和外部质量问题的早期反馈,以及如何确保测试继续支持变更并且不会成为进一步开发的障碍。

Once we’ve kick-started the TDD process, we need to keep it running smoothly. In this chapter we’ll show how a TDD process runs once started. The rest of the book explores in some detail how we ensure it runs smoothly—how we write tests as we build the system, how we use tests to get early feedback on internal and external quality issues, and how we ensure that the tests continue to support change and do not become an obstacle to further development.

每个功能都通过验收测试开始

Start Each Feature with an Acceptance Test

正如我们在第 1 章中所描述的,我们通过编写失败的验收测试来开始开发一个新功能,这些测试表明系统尚不具备我们即将编写的功能,并跟踪我们完成该功能的进度(图 5.1)。

As we described in Chapter 1, we start work on a new feature by writing failing acceptance tests that demonstrate that the system does not yet have the feature we’re about to write and track our progress towards completion of the feature (Figure 5.1).

图 5.1 每个 TDD 周期都以失败的验收测试开始

Figure 5.1 Each TDD cycle starts with a failing acceptance test

图像

我们仅使用应用程序领域的术语来编写验收测试,而不是使用底层技术(例如数据库或 Web 服务器)的术语。这有助于我们了解系统应该做什么,而不会将我们束缚在对实现的任何初始假设上,也不会用技术细节使测试复杂化。这还可以保护我们的验收测试套件不受系统技术基础设施更改的影响。例如,如果第三方组织将其服务使用的协议从 FTP 和二进制文件更改为 Web 服务和 XML,我们不必重新编写系统应用程序逻辑的测试。

We write the acceptance test using only terminology from the application’s domain, not from the underlying technologies (such as databases or web servers). This helps us understand what the system should do, without tying us to any of our initial assumptions about the implementation or complicating the test with technological details. This also shields our acceptance test suite from changes to the system’s technical infrastructure. For example, if a third-party organization changes the protocol used by their services from FTP and binary files to web services and XML, we should not have to rework the tests for the system’s application logic.

我们发现,在编码之前编写这样的测试可以让我们明确我们想要实现的目标。以可以自动检查的形式精确地表达需求有助于我们发现隐含的假设。失败的测试会继续让我们专注于实现他们描述的有限功能集,从而提高我们实现这些功能的机会。更微妙的是,从测试开始让我们从用户的角度看待系统,了解他们需要它做什么,而不是从实现者的角度推测功能。

We find that writing such a test before coding makes us clarify what we want to achieve. The precision of expressing requirements in a form that can be automatically checked helps us uncover implicit assumptions. The failing tests keep us focused on implementing the limited set of features they describe, improving our chances of delivering them. More subtly, starting with tests makes us look at the system from the users’ point of view, understanding what they need it to do rather than speculating about features from the implementers’ point of view.

另一方面,单元测试会单独测试对象或小对象集群。单元测试对于帮助我们设计类并让我们相信它们可以正常工作非常重要,但它们无法说明它们是否与系统的其余部分协同工作。验收测试既测试了单元测试对象的集成,又推动了项目的发展。

Unit tests, on the other hand, exercise objects, or small clusters of objects, in isolation. They’re important to help us design classes and give us confidence that they work, but they don’t say anything about whether they work together with the rest of the system. Acceptance tests both test the integration of unit-tested objects and push the project forwards.

将衡量进步的测试与捕捉倒退的测试区分开来

Separate Tests That Measure Progress from Those That Catch Regressions

当我们编写验收测试来描述新功能时,我们预计它们会失败,直到该功能实现;新的验收测试描述了尚未完成的工作。将验收测试从红色变为绿色的活动让团队可以衡量其所取得的进展。定期通过验收测试的周期是驱动我们在“反馈是基本工具” (第4页) 中描述的嵌套项目反馈循环的引擎。一旦通过,验收测试就代表功能已完成,不应再次失败。失败意味着出现了回归,意味着我们破坏了现有的代码。

When we write acceptance tests to describe a new feature, we expect them to fail until that feature has been implemented; new acceptance tests describe work yet to be done. The activity of turning acceptance tests from red to green gives the team a measure of the progress it’s making. A regular cycle of passing acceptance tests is the engine that drives the nested project feedback loops we described in “Feedback Is the Fundamental Tool” (page 4). Once passing, the acceptance tests now represent completed features and should not fail again. A failure means that there’s been a regression, that we’ve broken our existing code.

我们组织测试套件以反映测试所承担的不同角色。单元测试和集成测试支持开发团队,应快速运行,并且应始终通过。已完成功能的验收测试会捕获回归,并且应始终通过,尽管它们可能需要更长时间才能运行。新的验收测试代表正在进行的工作,在功能准备就绪之前不会通过。

We organize our test suites to reflect the different roles that the tests fulfill. Unit and integration tests support the development team, should run quickly, and should always pass. Acceptance tests for completed features catch regressions and should always pass, although they might take longer to run. New acceptance tests represent work in progress and will not pass until a feature is ready.

如果需求发生变化,我们必须将任何受影响的验收测试从回归套件移回到正在进行的套件中,对其进行编辑以反映新的需求,并更改系统以使它们再次通过。

If requirements change, we must move any affected acceptance tests out of the regression suite back into the in-progress suite, edit them to reflect the new requirements, and change the system to make them pass again.

从最简单的成功案例开始测试

Start Testing with the Simplest Success Case

当我们必须编写新类或新功能时,我们从哪里开始?我们很容易从退化或失败案例开始,因为它们通常更简单。这是对 XP 格言的常见解释,即做“可能行得通的最简单的事情” [Beck02],但简单不应被解释为过于简单。退化案例不会给系统带来太多价值,更重要的是,它们不会给我们足够的反馈来判断我们的想法是否有效。顺便说一句,我们还发现,在功能开始时关注失败案例不利于士气——如果我们只致力于错误处理,感觉就像我们没有取得任何成就。

Where do we start when we have to write a new class or feature? It’s tempting to start with degenerate or failure cases because they’re often easier. That’s a common interpretation of the XP maxim to do “the simplest thing that could possibly work” [Beck02], but simple should not be interpreted as simplistic. Degenerate cases don’t add much to the value of the system and, more importantly, don’t give us enough feedback about the validity of our ideas. Incidentally, we also find that focusing on the failure cases at the beginning of a feature is bad for morale—if we only work on error handling it feels like we’re not achieving anything.

我们倾向于从测试最简单的成功案例开始。一旦成功,我们将对解决方案的实际结构有更好的了解,并且可以优先处理我们在过程中注意到的任何可能失败和进一步的成功案例。当然,功能只有在强大之后才算完整。这不是不考虑失败处理的借口——但我们可以选择何时首先实现。

We prefer to start by testing the simplest success case. Once that’s working, we’ll have a better idea of the real structure of the solution and can prioritize between handling any possible failures we noticed along the way and further success cases. Of course, a feature isn’t complete until it’s robust. This isn’t an excuse not to bother with failure handling—but we can choose when we want to implement first.

我们发现在键盘旁放一本记事本或索引卡来记录失败案例、重构和其他需要解决的技术任务很有用。这使我们能够专注于手头的任务而不会忽略细节。只有当我们完成列表中的所有内容时,该功能才算完成——要么我们已经完成了每一项任务,要么决定我们不需要这样做。

We find it useful to keep a notepad or index cards by the keyboard to jot down failure cases, refactorings, and other technical tasks that need to be addressed. This allows us to stay focused on the task at hand without dropping detail. The feature is finished only when we’ve crossed off everything on the list—either we’ve done each task or decided that we don’t need to.

编写您想要阅读的测试

Write the Test That You’d Want to Read

我们希望每个测试都尽可能清晰地表达系统或对象要执行的行为。在编写测试时,我们忽略测试不会运行甚至无法编译的事实,而只关注其文本;我们表现得好像支持我们运行测试的代码已经存在一样。

We want each test to be as clear as possible an expression of the behavior to be performed by the system or object. While writing the test, we ignore the fact that the test won’t run, or even compile, and just concentrate on its text; we act as if the supporting code to let us run the test already exists.

当测试可读性良好时,我们便会构建支持测试的基础结构。当测试以我们预期的方式失败时,我们知道我们已经实现了足够的支持代码,并有一个清晰的错误消息描述需要做什么。只有这样,我们才开始编写代码以使测试通过。我们将在第 21 章中进一步讨论如何使测试可读性。

When the test reads well, we then build up the infrastructure to support the test. We know we’ve implemented enough of the supporting code when the test fails in the way we’d expect, with a clear error message describing what needs to be done. Only then do we start writing the code to make the test pass. We look further at making tests readable in Chapter 21.

观察测试失败

Watch the Test Fail

在编写代码使测试通过之前,我们总是观察测试是否失败,并检查诊断消息。如果测试以我们意想不到的方式失败,我们就知道我们误解了某些东西或代码不完整,所以我们会修复它。当我们得到“正确的”失败时,我们会检查诊断是否有用。如果故障描述不清楚,那么当代码在几周后崩溃时,某些人(可能是我们)将不得不苦苦挣扎。我们调整测试代码并重新运行测试,直到错误消息引导我们找到代码的问题(图 5.2)。

We always watch the test fail before writing the code to make it pass, and check the diagnostic message. If the test fails in a way we didn’t expect, we know we’ve misunderstood something or the code is incomplete, so we fix that. When we get the “right” failure, we check that the diagnostics are helpful. If the failure description isn’t clear, someone (probably us) will have to struggle when the code breaks in a few weeks’ time. We adjust the test code and rerun the tests until the error messages guide us to the problem with the code (Figure 5.2).

图 5.2 作为 TDD 周期的一部分改进诊断

Figure 5.2 Improving the diagnostics as part of the TDD cycle

图像

在编写生产代码时,我们会不断运行测试,以查看进度,并在测试后建立系统时检查错误诊断。必要时,我们会扩展或修改支持代码,以确保错误消息始终清晰且相关。

As we write the production code, we keep running the test to see our progress and to check the error diagnostics as the system is built up behind the test. Where necessary, we extend or modify the support code to ensure the error messages are always clear and relevant.

坚持检查错误信息的原因不止一个。首先,它可以检查我们对正在处理的代码的假设——有时我们错了。其次,更微妙的是,我们发现,我们强调(或许是狂热地)表达我们的意图对于开发可靠、可维护的系统至关重要——对我们来说,这包括测试和失败消息。费力地生成有用的诊断有助于我们明确测试以及代码应该做什么。我们将在第 23 章中讨论错误诊断以及如何改进它们。

There’s more than one reason for insisting on checking the error messages. First, it checks our assumptions about the code we’re working on—sometimes we’re wrong. Second, more subtly, we find that our emphasis on (or, perhaps, mania for) expressing our intentions is fundamental for developing reliable, maintainable systems—and for us that includes tests and failure messages. Taking the trouble to generate a useful diagnostic helps us clarify what the test, and therefore the code, is supposed to do. We look at error diagnostics and how to improve them in Chapter 23.

从输入到输出的发展

Develop from the Inputs to the Outputs

我们开始开发一项功能时,会考虑进入系统的事件,这些事件会触发新的行为。该功能的端到端测试将模拟这些事件的到来。在系统的边界,我们需要编写一个或多个对象来处理这些事件。在这样做的过程中,我们发现这些对象需要系统其余部分的支持服务才能履行其职责。我们编写更多对象来实现这些服务,并发现这些新对象依次需要哪些服务。

We start developing a feature by considering the events coming into the system that will trigger the new behavior. The end-to-end tests for the feature will simulate these events arriving. At the boundaries of our system, we will need to write one or more objects to handle these events. As we do so, we discover that these objects need supporting services from the rest of the system to perform their responsibilities. We write more objects to implement these services, and discover what services these new objects need in turn.

通过这种方式,我们遍历整个系统:从接收外部事件的对象,通过中间层,到中心域模型,再到生成外部可见响应的其他边界对象。这可能意味着接受一些文本和鼠标单击并在数据库中查找记录,或者接收队列中的消息并在服务器上查找文件。

In this way, we work our way through the system: from the objects that receive external events, through the intermediate layers, to the central domain model, and then on to other boundary objects that generate an externally visible response. That might mean accepting some text and a mouse click and looking for a record in a database, or receiving a message in a queue and looking for a file on a server.

一开始先对新的领域模型对象进行单元测试,然后尝试将它们挂接到应用程序的其余部分,这种做法很诱人。一开始似乎比较容易——我们觉得在领域模型上取得的进展很快,因为我们不需要让它适应任何东西——但以后我们更有可能遇到集成问题。我们将浪费时间构建不必要或不正确的功能,因为我们在处理这些功能时没有收到正确的反馈。

It’s tempting to start by unit-testing new domain model objects and then trying to hook them into the rest of the application. It seems easier at the start—we feel we’re making rapid progress working on the domain model when we don’t have to make it fit into anything—but we’re more likely to get bitten by integration problems later. We’ll have wasted time building unnecessary or incorrect functionality, because we weren’t receiving the right kind of feedback when we were working on it.

对行为进行单元测试,而不是对方法进行单元测试

Unit-Test Behavior, Not Methods

我们通过惨痛经历认识到,仅仅编写大量测试,即使能产生高测试覆盖率,也不能保证代码库易于使用。许多采用 TDD 的开发人员发现,当他们以后重新审视早期的测试时,很难理解它们,而一个常见的错误就是考虑测试方法。一个名为 的测试testBidAccepted()告诉我们它做什么,但没有告诉我们它是为了什么。

We’ve learned the hard way that just writing lots of tests, even when it produces high test coverage, does not guarantee a codebase that’s easy to work with. Many developers who adopt TDD find their early tests hard to understand when they revisit them later, and one common mistake is thinking about testing methods. A test called testBidAccepted() tells us what it does, but not what it’s for.

当我们专注于测试对象应提供的功能时,我们会做得更好,每个功能都可能需要与其邻居协作并调用其多个方法。我们需要知道如何使用该类来实现目标,而不是如何通过其代码执行所有路径。

We do better when we focus on the features that the object under test should provide, each of which may require collaboration with its neighbors and calling more than one of its methods. We need to know how to use the class to achieve a goal, not how to exercise all the paths through its code.

选择能够描述对象在测试场景中的行为方式的测试名称会有所帮助。我们将在“测试名称描述特性” (第248页)中更详细地讨论这一点。

It helps to choose test names that describe how the object behaves in the scenario being tested. We look at this in more detail in “Test Names Describe Features” (page 248).

聆听测试

Listen to the Tests

在编写单元测试和集成测试时,我们会对代码中难以测试的部分保持警惕。当我们发现某个功能难以测试时,我们不仅会问自己如何测试它,还会问它为何难以测试。

When writing unit and integration tests, we stay alert for areas of the code that are difficult to test. When we find a feature that’s difficult to test, we don’t just ask ourselves how to test it, but also why is it difficult to test.

我们的经验是,当代码难以测试时,最可能的原因是我们的设计需要改进。现在使代码难以测试的相同结构将使代码在将来难以更改。到那时,更改将更加困难,因为我们会忘记编写代码时的想法。对于一个成功的系统,甚至可能是一个完全不同的团队必须承担我们决策的后果。

Our experience is that, when code is difficult to test, the most likely cause is that our design needs improving. The same structure that makes the code difficult to test now will make it difficult to change in the future. By the time that future comes around, a change will be more difficult still because we’ll have forgotten what we were thinking when we wrote the code. For a successful system, it might even be a completely different team that will have to live with the consequences of our decisions.

我们的应对措施是将编写测试的过程视为潜在维护问题的宝贵早期预警,并利用这些提示在问题仍然新鲜时解决问题。如图 5.3所示,如果我们发现很难编写下一个失败的测试,我们会再次查看生产代码的设计,并经常在继续之前对其进行重构。

Our response is to regard the process of writing tests as a valuable early warning of potential maintenance problems and to use those hints to fix a problem while it’s still fresh. As Figure 5.3 shows, if we’re finding it hard to write the next failing test, we look again at the design of the production code and often refactor it before moving on.

图 5.3 编写测试的困难可能意味着需要修复生产代码

Figure 5.3 Difficulties writing tests may suggest a need to fix production code

图像

这是我们的格言“预期意外变化”如何指导开发的一个例子。如果我们在发现设计中的弱点时通过重构来保持系统的质量,我们将能够使其对出现的任何变化做出反应。另一种选择是通常的“软件腐烂”,即代码腐烂,直到团队无法响应客户的需求。我们将在第 20 章中回到这个主题。

This is an example of how our maxim—“Expect Unexpected Changes”—guides development. If we keep up the quality of the system by refactoring when we see a weakness in the design, we will be able to make it respond to whatever changes turn up. The alternative is the usual “software rot” where the code decays until the team just cannot respond to the needs of its customers. We’ll return to this topic in Chapter 20.

调整周期

Tuning the Cycle

在详尽测试执行路径和测试集成之间需要保持平衡。如果我们测试的粒度过大,尝试代码中所有可能路径的组合爆炸将使开发陷入停顿。更糟糕的是,其中一些路径(例如抛出模糊异常)从该级别进行测试是不切实际的。另一方面,如果我们测试的粒度过细(例如仅在类级别),测试将更容易,但我们会错过因对象无法协同工作而引起的问题。

There’s a balance between exhaustively testing execution paths and testing integration. If we test at too large a grain, the combinatorial explosion of trying all the possible paths through the code will bring development to a halt. Worse, some of those paths, such as throwing obscure exceptions, will be impractical to test from that level. On the other hand, if we test at too fine a grain—just at the class level, for example—the testing will be easier but we’ll miss problems that arise from objects not working together.

我们应该进行多少单元测试,使用模拟对象来打破外部依赖关系,以及进行多少集成测试?我们认为这个问题没有唯一的答案。这在很大程度上取决于团队及其环境。我们从 TDD 的测试部分(很多)中所能获得的最好东西是,我们可以在不破坏代码的情况下更改代码:恐惧会扼杀进步。诀窍是确保信心是合理的。

How much unit testing should we do, using mock objects to break external dependencies, and how much integration testing? We don’t think there’s a single answer to this question. It depends too much on the context of the team and its environment. The best we can get from the testing part of TDD (which is a lot) is the confidence that we can change the code without breaking it: Fear kills progress. The trick is to make sure that the confidence is justified.

因此,我们会定期反思 TDD 的效果如何,找出任何弱点,并调整我们的测试策略。逻辑上的复杂部分可能需要更多的单元测试(或者简化);未处理的异常可能需要更多的集成级测试;而且,意外的系统故障将需要更多的调查,甚至可能需要更多的测试。

So, we regularly reflect on how well TDD is working for us, identify any weaknesses, and adapt our testing strategy. Fiddly bits of logic might need more unit testing (or, alternatively, simplification); unhandled exceptions might need more integration-level testing; and, unexpected system failures will need more investigation and, possibly, more testing throughout.

第 6 章 面向对象风格

Chapter 6. Object-Oriented Style

设计事物时一定要考虑其更大的背景——房间里的椅子、房子里的房间、环境中的房子、城市规划中的环境。

Always design a thing by considering it in its next larger context—a chair in a room, a room in a house, a house in an environment, an environment in a city plan.

—埃利尔·沙里宁

—Eliel Saarinen

介绍

Introduction

到目前为止,在第二部分中,我们讨论了如何开始开发过程以及如何继续进行。现在,我们想更详细地了解一下我们的设计目标以及我们对 TDD 的使用,特别是模拟对象,以指导我们的代码结构。

So far in Part II, we’ve talked about how to get started with the development process and how to keep going. Now we want to take a more detailed look at our design goals and our use of TDD, and in particular mock objects, to guide the structure of our code.

与易于编写的代码相比,我们更看重易于维护的代码。1以最直接的方式实现功能可能会损害系统的可维护性,例如,使代码难以理解或在组件之间引入隐藏的依赖关系。平衡短期和长期问题通常很棘手,但我们已经看到太多团队因为系统太脆弱而无法交付。

We value code that is easy to maintain over code that is easy to write.1 Implementing a feature in the most direct way can damage the maintainability of the system, for example by making the code difficult to understand or by introducing hidden dependencies between components. Balancing immediate and longer-term concerns is often tricky, but we’ve seen too many teams that can no longer deliver because their system is too brittle.

1.正如敏捷宣言所说。

1. As the Agile Manifesto might have put it.

在本章中,我们想展示一下我们在设计软件时想要实现的目标,以及它在面向对象语言中的样子;这是我们软件方法的“固执己见”部分。在下一章中,我们将了解如何使用 TDD 引导代码朝这个方向发展的机制。

In this chapter, we want to show something of what we’re trying to achieve when we design software, and how that looks in an object-oriented language; this is the “opinionated” part of our approach to software. In the next chapter, we’ll look at the mechanics of how to guide code in this direction with TDD.

可维护性设计

Designing for Maintainability

按照我们在第 5 章中描述的过程,我们一次增加系统的一部分功能。随着代码规模的扩大,我们能够继续理解和维护它的唯一方法是将功能构造成对象,将对象构造成包,2将软件包整合成程序,将程序整合成系统。我们使用两种主要启发式方法来指导这种结构:

Following the process we described in Chapter 5, we grow our systems a slice of functionality at a time. As the code scales up, the only way we can continue to understand and maintain it is by structuring the functionality into objects, objects into packages,2 packages into programs, and programs into systems. We use two principal heuristics to guide this structuring:

2.我们在这里对“包”的含义含糊其辞,因为我们希望它包含模块、库和命名空间等概念,这些概念在 Java 世界中往往会混淆——但您知道我们的意思。

2. We’re being vague about the meaning of “package” here since we want it to include concepts such as modules, libraries, and namespaces, which tend to be confounded in the Java world—but you know what we mean.

关注点分离

Separation of concerns

当我们必须更改系统的行为时,我们希望尽可能少地更改代码。如果所有相关更改都在代码的一个区域,我们就不必在系统中四处寻找来完成工作。由于我们无法预测何时必须更改系统的任何特定部分,因此我们将因相同原因而更改的代码集中在一起。例如,用于从 Internet 标准协议中解包消息的代码不会因与解释这些消息的业务代码相同的原因而更改,因此我们将这两个概念划分为不同的包。

When we have to change the behavior of a system, we want to change as little code as possible. If all the relevant changes are in one area of code, we don’t have to hunt around the system to get the job done. Because we cannot predict when we will have to change any particular part of the system, we gather together code that will change for the same reason. For example, code to unpack messages from an Internet standard protocol will not change for the same reasons as business code that interprets those messages, so we partition the two concepts into different packages.

更高层次的抽象

Higher levels of abstraction

人类应对复杂性的唯一方法是避免它,即在更高的抽象层次上工作。如果我们通过组合有用功能的组件而不是操纵变量和控制流来编程,我们可以完成更多工作;这就是为什么大多数人从菜单上按菜品点菜,而不是详细列出制作这些菜品的食谱。

The only way for humans to deal with complexity is to avoid it, by working at higher levels of abstraction. We can get more done if we program by combining components of useful functionality rather than manipulating variables and control flow; that’s why most people order food from a menu in terms of dishes, rather than detail the recipes used to create them.

持续应用这两种力量,将推动应用程序的结构朝着类似 Cockburn 的“端口和适配器”架构[Cockburn08] 的方向发展,其中业务领域的代码与其对技术基础设施(如数据库和用户界面)的依赖隔离。我们不希望技术概念泄露到应用程序模型中,因此我们编写接口来用其术语描述其与外界的关系(Cockburn 的端口)。然后我们在应用程序核心和每个技术领域之间建立桥梁(Cockburn 的适配器)。这与 Eric Evans 所说的“反腐层” [Evans03]有关。

Applied consistently, these two forces will push the structure of an application towards something like Cockburn’s “ports and adapters” architecture [Cockburn08], in which the code for the business domain is isolated from its dependencies on technical infrastructure, such as databases and user interfaces. We don’t want technical concepts to leak into the application model, so we write interfaces to describe its relationships with the outside world in its terminology (Cockburn’s ports). Then we write bridges between the application core and each technical domain (Cockburn’s adapters). This is related to what Eric Evans calls an “anticorruption layer” [Evans03].

桥接器实现由应用程序模型定义的接口,并在应用程序级和技术级对象之间进行映射(图 6.1)。例如,桥接器可以将订单簿对象映射到 SQL 语句,以便将订单持久保存在数据库中。为此,它可能从应用程序对象查询值或使用对象关系工具(如 Hibernate)3使用 Java 反射从对象中提取值。我们将在第 17 章中展示重构此架构的示例。

The bridges implement the interfaces defined by the application model and map between application-level and technical-level objects (Figure 6.1). For example, a bridge might map an order book object to SQL statements so that orders are persisted in a database. To do so, it might query values from the application object or use an object-relational tool like Hibernate3 to pull values out of objects using Java reflection. We’ll show an example of refactoring to this architecture in Chapter 17.

3. http://www.hibernate.org

3. http://www.hibernate.org

图 6.1 应用程序的核心领域模型映射到技术基础设施上

Figure 6.1 An application’s core domain model is mapped onto technical infrastructure

图像

下一个问题是如何在行为中找到接口应该在的位置,以便我们可以清晰地划分代码。我们有一些二级启发式方法可以帮助我们思考这个问题。

The next question is how to find the facets in the behavior where the interfaces should be, so that we can divide up the code cleanly. We have some second-level heuristics to help us think about that.

内部与同行

Internals vs. Peers

在组织系统时,我们必须确定每个对象的内部和外部,以便对象提供具有清晰 API 的连贯抽象。正如我们上面所讨论的,对象的大部分目的是通过其 API 封装对其内部的访问,并向系统的其他部分隐藏这些细节。对象通过发送和接收消息与系统中的其他对象进行通信,如图6.2所示;它直接与之通信的对象是它的对等体

As we organize our system, we must decide what is inside and outside each object, so that the object provides a coherent abstraction with a clear API. Much of the point of an object, as we discussed above, is to encapsulate access to its internals through its API and to hide these details from the rest of the system. An object communicates with other objects in the system by sending and receiving messages, as in Figure 6.2; the objects it communicates with directly are its peers.

图 6.2 对象通过发送和接收消息进行通信

Figure 6.2 Objects communicate by sending and receiving messages

图像

这个决定很重要,因为它会影响对象的易用性,从而影响系统的内部质量。如果我们通过 API 公开过多对象的内部信息,其客户端最终会执行部分工作。我们将在太多对象上实现分布式行为(它们将耦合在一起),这会增加维护成本,因为任何更改现在都会影响整个代码。这是第17页上的“火车失事”示例的影响:

This decision matters because it affects how easy an object is to use, and so contributes to the internal quality of the system. If we expose too much of an object’s internals through its API, its clients will end up doing some of its work. We’ll have distributed behavior across too many objects (they’ll be coupled together), increasing the cost of maintenance because any changes will now ripple across the code. This is the effect of the “train wreck” example on page 17:

((编辑保存自定义器) master.getModelisable()

.getDockablePanel()

.getCustomizer())

.getSaveItem().setEnabled(Boolean.FALSE.booleanValue());

((EditSaveCustomizer) master.getModelisable()

.getDockablePanel()

.getCustomizer())

.getSaveItem().setEnabled(Boolean.FALSE.booleanValue());

本例中的每个 getter 都暴露了一个结构细节。如果我们想改变启用自master定义的方式,就必须更改所有中间关系。

Every getter in this example exposes a structural detail. If we wanted to change, say, the way customizations on the master are enabled, we’d have to change all the intermediate relationships.

那么我们如何为一个物体选择正确的特征呢?

So how do we choose the right features for an object?

没有“并且”、“或者”或“但是”

No And’s, Or’s, or But’s

每个对象都应该具有单一、明确定义的职责;这就是“单一职责”原则[Martin02]。当我们向系统添加行为时,此原则可帮助我们决定是否扩展现有对象或为对象调用创建新服务。

Every object should have a single, clearly defined responsibility; this is the “single responsibility” principle [Martin02]. When we’re adding behavior to a system, this principle helps us decide whether to extend an existing object or create a new service for an object to call.

我们的启发式方法是,我们应该能够在不使用任何连词(“和”、“或”)的情况下描述对象的作用。如果我们发现自己在描述中添加了子句,那么对象可能应该被分解为协作对象,通常每个子句一个。

Our heuristic is that we should be able to describe what an object does without using any conjunctions (“and,” “or”). If we find ourselves adding clauses to the description, then the object probably should be broken up into collaborating objects, usually one for each clause.

当我们将对象组合成新的抽象时,此原则也适用。如果我们将跨多个对象实现的行为打包成一个构造,我们应该能够清楚地描述其职责;下面的“组合比各部分之和更简单”和“上下文独立性”部分中有一些相关的想法。

This principle also applies when we’re combining objects into new abstractions. If we’re packaging up behavior implemented across several objects into a single construct, we should be able to describe its responsibility clearly; there are some related ideas below in the “Composite Simpler Than the Sum of Its Parts” and “Context Independence” sections.

对象同侪刻板印象

Object Peer Stereotypes

我们拥有具有单一职责的对象,通过干净的 API 中的消息与同行进行通信,但是他们彼此之间说了什么?

We have objects with single responsibilities, communicating with their peers through messages in clean APIs, but what do they say to each other?

我们将对象的对等体(大致)分为三种关系。一个对象可能有:

We categorize an object’s peers (loosely) into three types of relationship. An object might have:

依赖项

Dependencies

对象需要从其同级获取的服务,以便能够履行其职责。没有这些服务,对象就无法运行。没有它们,就不可能创建对象。例如,图形包需要屏幕或画布之类的东西来绘制 - 没有它们就没有意义。

Services that the object requires from its peers so it can perform its responsibilities. The object cannot function without these services. It should not be possible to create the object without them. For example, a graphics package will need something like a screen or canvas to draw on—it doesn’t make sense without one.

通知

Notifications

需要随时了解对象活动的对等点。对象会在改变状态或执行重要操作时通知感兴趣的对等点。通知是“发射后不管”的;对象既不知道也不关心哪些对等点在监听。通知非常有用,因为它们将对象彼此分离。例如,在用户界面系统中,按钮组件承诺在单击时通知任何已注册的侦听器,但不知道这些侦听器将执行什么操作。类似地,侦听器期望被调用,但对用户界面调度其事件的方式一无所知。

Peers that need to be kept up to date with the object’s activity. The object will notify interested peers whenever it changes state or performs a significant action. Notifications are “fire and forget”; the object neither knows nor cares which peers are listening. Notifications are so useful because they decouple objects from each other. For example, in a user interface system, a button component promises to notify any registered listeners when it’s clicked, but does not know what those listeners will do. Similarly, the listeners expect to be called but know nothing of the way the user interface dispatches its events.

调整

Adjustments

同级对象会根据系统的更广泛需求调整对象的行为。这包括代表对象做出决策的策略对象([Gamma94]中的策略模式)和对象(如果是复合对象)的组成部分。例如,SwingJTable将要求TableCellRenderer绘制单元格的值,可能是颜色的 RGB(红、绿、蓝)值。如果我们更改渲染器,表格将更改其显示方式,现在显示 HSB(色调、饱和度、亮度)值。

Peers that adjust the object’s behavior to the wider needs of the system. This includes policy objects that make decisions on the object’s behalf (the Strategy pattern in [Gamma94]) and component parts of the object if it’s a composite. For example, a Swing JTable will ask a TableCellRenderer to draw a cell’s value, perhaps as RGB (Red, Green, Blue) values for a color. If we change the renderer, the table will change its presentation, now displaying the HSB (Hue, Saturation, Brightness) values.

这些刻板印象只是帮助我们思考设计的启发式方法,而不是硬性规定,因此我们不必执着于找到对象对等体的正确分类。最重要的是协作对象的使用环境。例如,在一个应用程序中,审计日志可能是一种依赖项,因为审计是业务的合法要求,没有审计线索就不应创建任何对象。在其他地方,它可能是一种通知,因为审计是用户的选择,没有审计线索,对象也能完美运行。

These stereotypes are only heuristics to help us think about the design, not hard rules, so we don’t obsess about finding just the right classification of an object’s peers. What matters most is the context in which the collaborating objects are used. For example, in one application an auditing log could be a dependency, because auditing is a legal requirement for the business and no object should be created without an audit trail. Elsewhere, it could be a notification, because auditing is a user choice and objects will function perfectly well without it.

另一种看待它的方式是,通知是单向的:通知侦听器可能不会返回值、回调调用者或抛出异常,因为链中可能还有其他侦听器。另一方面,依赖项或调整可以执行其中任何一项,因为存在直接关系。

Another way to look at it is that notifications are one-way: A notification listener may not return a value, call back the caller, or throw an exception, since there may be other listeners further down the chain. A dependency or adjustment, on the other hand, may do any of these, since there’s a direct relationship.

复合材料比其各部分之和更简单

Composite Simpler Than the Sum of Its Parts

系统中的所有对象(语言内置的原始类型除外)都是由其他对象组成的。当将对象组合成新类型时,我们希望新类型的行为比其所有组成部分加起来的行为更简单。复合对象的 API 必须隐藏其组成部分的存在及其之间的交互,并向其同类对象公开更简单的抽象。想象一下机械钟:它有两三个指针用于输出,一个拉出式轮用于输入,但包装了数十个活动部件。

All objects in a system, except for primitive types built into the language, are composed of other objects. When composing objects into a new type, we want the new type to exhibit simpler behavior than all of its component parts considered together. The composite object’s API must hide the existence of its component parts and the interactions between them, and expose a simpler abstraction to its peers. Think of a mechanical clock: It has two or three hands for output and one pull-out wheel for input but packages up dozens of moving parts.

在软件中,用于编辑货币值的用户界面组件可能有两个子组件:一个用于金额,一个用于货币。为了使组件有用,其 API 应该同时管理这两个值,否则客户端代码可以直接控制其子组件。

In software, a user interface component for editing money values might have two subcomponents: one for the amount and one for the currency. For the component to be useful, its API should manage both values together, otherwise the client code could just control it subcomponents directly.

moneyEditor.getAmountField().设置文本(String.valueOf(money.amount());

moneyEditor.getCurrencyField().设置文本(money.currencyCode());

moneyEditor.getAmountField().setText(String.valueOf(money.amount());

moneyEditor.getCurrencyField().setText(money.currencyCode());

“告诉,不要询问”惯例可以开始向客户端隐藏对象的结构,但本身并不是一个足够强大的规则。例如,我们可以用 setter 替换第一个版本中的 getter:

The “Tell, Don’t Ask” convention can start to hide an object’s structure from its clients but is not a strong enough rule by itself. For example, we could replace the getters in the first version with setters:

moneyEditor.设置AmountField(money.amount());

moneyEditor.设置CurrencyField(money.currencyCode());

moneyEditor.setAmountField(money.amount());

moneyEditor.setCurrencyField(money.currencyCode());

这仍然暴露了组件的内部结构,其客户端仍然必须明确管理。

This still exposes the internal structure of the component, which its client still has to manage explicitly.

我们可以通过在组件内隐藏有关货币值显示和编辑方式的所有内容来使 API 变得更加简单,从而简化客户端代码:

We can make the API much simpler by hiding within the component everything about the way money values are displayed and edited, which in turn simplifies the client code:

moneyEditor.设置值(金钱);

moneyEditor.setValue(money);

这表明了一条经验法则:

This suggests a rule of thumb:

复合材料比其各部分之和更简单

Composite Simpler Than the Sum of Its Parts

图像

复合对象的 API 不应该比其任何组件的 API 更复杂。

The API of a composite object should not be more complicated than that of any of its components.

当然,复合对象可以用作更大规模、更复杂的复合对象的组件。随着代码的增长,“复合比各部分之和更简单”的规则有助于提高抽象级别。

Composite objects can, of course, be used as components in larger-scale, more sophisticated composite objects. As we grow the code, the “composite simpler than the sum of its parts” rule contributes to raising the level of abstraction.

上下文独立性

Context Independence

“整体比各部分之和简单”规则可以帮助我们判断一个对象是否隐藏了足够的信息,而“上下文独立性”规则则可以帮助我们判断一个对象是否隐藏了太多信息或隐藏了错误的信息。

While the “composite simpler than the sum of its parts” rule helps us decide whether an object hides enough information, the “context independence” rule helps us decide whether an object hides too much or hides the wrong information.

如果系统的对象是上下文无关的,即如果每个对象都没有关于其执行系统的内置知识,则系统更容易更改。这使我们能够采用行为单元(对象)并将其应用于新情况。要与上下文无关,必须传入对象需要了解的有关其运行的更大环境的任何信息。这些关系可能是“永久的”(在构造时传递)或“瞬态的”(传递给需要它们的方法)。

A system is easier to change if its objects are context-independent; that is, if each object has no built-in knowledge about the system in which it executes. This allows us to take units of behavior (objects) and apply them in new situations. To be context-independent, whatever an object needs to know about the larger environment it’s running in must be passed in. Those relationships might be “permanent” (passed in on construction) or “transient” (passed in to the method that needs them).

在这种“家长式”方法中,每个对象只被告知其工作所需的信息,并被包装在与其词汇相匹配的抽象中。最终,对象链到达进程边界,系统将在此找到外部详细信息,例如主机名、端口和用户界面事件。

In this “paternalistic” approach, each object is told just enough to do its job and wrapped up in an abstraction that matches its vocabulary. Eventually, the chain of objects reaches a process boundary, which is where the system will find external details such as host names, ports, and user interface events.

单一领域词汇

One Domain Vocabulary

图像

使用来自多个领域的术语的类可能会违反上下文独立性,除非它是桥接层的一部分。

A class that uses terms from multiple domains might be violating context independence, unless it’s part of a bridging layer.

“上下文独立性”规则对对象系统的作用是使它们的关系明确化,与对象本身分开定义。首先,这简化了对象,因为它们不需要管理自己的关系。其次,这简化了关系的管理,因为同一规模的对象通常在同一个地方创建和组合,通常是在映射层工厂对象中。

The effect of the “context independence” rule on a system of objects is to make their relationships explicit, defined separately from the objects themselves. First, this simplifies the objects, since they don’t need to manage their own relationships. Second, this simplifies managing the relationships, since objects at the same scale are often created and composed together in the same places, usually in mapping-layer factory objects.

上下文独立性引导我们走向可以在不同上下文中应用的连贯对象,以及我们可以通过重新配置对象的组成方式来改变的系统。

Context independence guides us towards coherent objects that can be applied in different contexts, and towards systems that we can change by reconfiguring how their objects are composed.

隐藏正确信息

Hiding the Right Information

封装几乎总是一件好事,但有时信息可能会隐藏在错误的地方。这使得代码难以理解、集成或通过组合对象来构建行为。最好的防御方法是在讨论设计时明确这两个概念之间的区别。例如,我们可以说:

Encapsulation is almost always a good thing to do, but sometimes information can be hidden in the wrong place. This makes the code difficult to understand, to integrate, or to build behavior from by composing objects. The best defense is to be clear about the difference between the two concepts when discussing a design. For example, we might say:

• “将缓存的数据结构封装在CachingAuctionLoader类中。”

• “Encapsulate the data structure for the cache in the CachingAuctionLoader class.”

• “将应用程序的日志文件的名称封装在PricingPolicy类中。”

• “Encapsulate the name of the application’s log file in the PricingPolicy class.”

这些听起来很合理,直到我们从信息隐藏的角度重新表述它们:

These sound reasonable until we recast them in terms of information hiding:

• “隐藏类中用于缓存的数据结构CachingAuctionLoader。”

• “Hide the data structure used for the cache in the CachingAuctionLoader class.”

• “隐藏类中应用程序的日志文件的名称PricingPolicy。”

• “Hide the name of the application’s log file in the PricingPolicy class.”

上下文独立性告诉我们,我们没必要在PricingPolicy类中隐藏日志文件的详细信息——它们是嵌套域的“俄罗斯套娃”结构中不同级别的概念。如果需要日志文件名,则应将其打包并从理解外部配置的级别传入。

Context independence tells us that we have no business hiding details of the log file in the PricingPolicy class—they’re concepts from different levels in the “Russian doll” structure of nested domains. If the log file name is necessary, it should be packaged up and passed in from a level that understands external configuration.

主观观点

An Opinionated View

我们花了时间描述我们认为的“良好”面向对象设计,因为它是我们开发方法的基础,我们发现它有助于我们编写可以轻松扩展和适应的代码,以满足用户不断变化的需求。现在我们想展示我们的测试驱动开发方法如何支持这些原则。

We’ve taken the time to describe what we think of as “good” object-oriented design because it underlies our approach to development and we find that it helps us write code that we can easily grow and adapt to meet the changing needs of its users. Now we want to show how our approach to test-driven development supports these principles.

第 7 章 实现面向对象设计

Chapter 7. Achieving Object-Oriented Design

在风格问题上,顺应潮流;在原则问题上,坚如磐石。

In matters of style, swim with the current; in matters of principle, stand like a rock.

—托马斯·杰斐逊

—Thomas Jefferson

编写测试如何帮助设计

How Writing a Test First Helps the Design

我们在上一章中概述的设计原则适用于为对象找到正确的边界,以便它与邻居很好地协作——调用者想知道对象做什么以及它依赖什么,但不知道它是如何工作的。我们还希望对象代表一个在更大环境中有意义的连贯单元。由此类组件构建的系统将具有随着需求变化而重新配置和调整的灵活性。

The design principles we outlined in the previous chapter apply to finding the right boundaries for an object so that it plays well with its neighbors—a caller wants to know what an object does and what it depends on, but not how it works. We also want an object to represent a coherent unit that makes sense in its larger environment. A system built from such components will have the flexibility to reconfigure and adapt as requirements change.

TDD 有三个方面可以帮助我们实现这一范围界定。首先,从测试开始意味着我们必须先描述我们想要实现的目标,然后再考虑如何实现。这种关注有助于我们保持目标对象的正确抽象级别。如果单元测试的意图不明确,那么我们可能会混淆概念,并且还没有准备好开始编码。它还有助于我们隐藏信息,因为我们必须决定从对象外部需要看到什么。

There are three aspects of TDD that help us achieve this scoping. First, starting with a test means that we have to describe what we want to achieve before we consider how. This focus helps us maintain the right level of abstraction for the target object. If the intention of the unit test is unclear then we’re probably mixing up concepts and not ready to start coding. It also helps us with information hiding as we have to decide what needs to be visible from outside the object.

其次,为了让单元测试易于理解(并且易于维护),我们必须限制其范围。我们见过长达数十行的单元测试,将测试的要点埋藏在设置中的某个地方。这样的测试告诉我们,它们测试的组件太大,需要分解成较小的组件。随着我们梳理出其隐式结构,生成的复合对象应该具有更清晰的关注点分离,并且我们可以为提取的对象编写更简单的测试。

Second, to keep unit tests understandable (and, so, maintainable), we have to limit their scope. We’ve seen unit tests that are dozens of lines long, burying the point of the test somewhere in its setup. Such tests tell us that the component they’re testing is too large and needs breaking up into smaller components. The resulting composite object should have a clearer separation of concerns as we tease out its implicit structure, and we can write simpler tests for the extracted objects.

第三,要为单元测试构建一个对象,我们必须将其依赖项传递给它,这意味着我们必须知道它们是什么。这鼓励了上下文独立性,因为我们必须能够设置目标对象的环境,然后才能对其进行单元测试 - 单元测试只是另一个上下文。我们会注意到,具有隐式(或太多)依赖项的对象很难为测试做准备 - 并着重清理它。

Third, to construct an object for a unit test, we have to pass its dependencies to it, which means that we have to know what they are. This encourages context independence, since we have to be able to set up the target object’s environment before we can unit-test it—a unit test is just another context. We’ll notice that an object with implicit (or just too many) dependencies is painful to prepare for testing—and make a point of cleaning it up.

在本章中,我们将描述如何使用增量、测试驱动的方法来推动我们的代码朝着上一章描述的设计原则前进。

In this chapter, we describe how we use an incremental, test-driven approach to nudge our code towards the design principles we described in the previous chapter.

分类沟通

Communication over Classification

正如我们在第 2 章中所写,我们将运行系统视为一个通信对象的网络,因此我们将设计重点放在对象如何协作以提供我们所需的功能上。显然,我们希望实现一个设计良好的类结构,但我们认为对象之间的通信模式更为重要。

As we wrote in Chapter 2, we view a running system as a web of communicating objects, so we focus our design effort on how the objects collaborate to deliver the functionality we need. Obviously, we want to achieve a well-designed class structure, but we think the communication patterns between objects are more important.

在 Java 等语言中,我们可以使用接口来定义对象之间的可用消息,但我们还需要定义它们的通信模式,即通信协议。我们尽力使用命名和约定,但语言中没有任何东西可以描述接口之间或接口内方法之间的关系,这使得设计的很大一部分变得隐晦。

In languages such as Java, we can use interfaces to define the available messages between objects, but we also need to define their patterns of communication—their communication protocols. We do what we can with naming and convention, but there’s nothing in the language to describe relationships between interfaces or methods within an interface, which leaves a significant part of the design implicit.

接口和协议

Interface and Protocol

图像

史蒂夫在一次会议演讲中听到了这个有用的区别:接口描述两个组件是否可以组合在一起,而协议描述它们是否可以一起工作

Steve heard this useful distinction in a conference talk: an interface describes whether two components will fit together, while a protocol describes whether they will work together.

我们使用 TDD 和模拟对象作为一种技术来使这些通信协议可见,既可以作为在开发过程中发现它们的工具,也可以作为重新访问代码时的描述。例如,第 3 章末尾的单元测试告诉我们,给定某个输入消息,translator应该只调用listener.auctionClosed()一次 - 而不是其他任何方法。虽然listener接口有其他方法,但这个测试表明其协议要求auctionClosed()应该单独调用。

We use TDD with mock objects as a technique to make these communication protocols visible, both as a tool for discovering them during development and as a description when revisiting the code. For example, the unit test towards the end of Chapter 3 tells us that, given a certain input message, the translator should call listener.auctionClosed() exactly once—and nothing else. Although the listener interface has other methods, this test says that its protocol requires that auctionClosed() should be called on its own.

@Test public void

notifiesAuctionClosedWhenCloseMessageReceived() {

Message message = new Message();

message.setBody("SOLVersion: 1.1; 事件:CLOSE;");



context.checking(new Expectations() {{

oneOf(listener).auctionClosed();

}});



translator.processMessage(UNUSED_CHAT, message);

}

@Test public void

notifiesAuctionClosedWhenCloseMessageReceived() {

Message message = new Message();

message.setBody("SOLVersion: 1.1; Event: CLOSE;");



context.checking(new Expectations() {{

oneOf(listener).auctionClosed();

}});



translator.processMessage(UNUSED_CHAT, message);

}

使用模拟对象的 TDD 还鼓励信息隐藏。我们应该模拟对象的对等体(我们在52页上对其进行了分类的依赖项、通知和调整) ,但不要模拟其内部。突出显示对象的邻居的测试有助于我们了解它们是否同级,或者应该位于目标对象的内部。笨拙或不清楚的测试可能暗示我们暴露了太多的实现,我们应该重新平衡对象及其邻居之间的职责。

TDD with mock objects also encourages information hiding. We should mock an object’s peers—its dependencies, notifications, and adjustments we categorized on page 52—but not its internals. Tests that highlight an object’s neighbors help us to see whether they are peers, or should instead be internal to the target object. A test that is clumsy or unclear might be a hint that we’ve exposed too much implementation, and that we should rebalance the responsibilities between the object and its neighbors.

值类型

Value Types

在进一步讨论之前,我们想重新回顾一下在“值和对象”(第13页)中描述的区别:是不可变的,因此它们更简单并且没有有意义的身份;对象有状态,因此它们有身份和相互关系。

Before we go further, we want to revisit the distinction we described in “Values and Objects” (page 13): values are immutable, so they’re simpler and have no meaningful identity; objects have state, so they have identity and relationships with each other.

我们编写的代码越多,我们就越相信应该定义类型来表示领域中的值概念,即使它们的作用不大。这有助于创建一个更不言自明的一致领域模型。例如,如果我们Item在系统中创建一种类型,而不是仅仅使用String,我们就可以找到与更改相关的所有代码,而不必追踪方法调用。特定类型还可以降低混淆的风险——正如火星气候探测器灾难所显示的那样,英尺和米都可以表示为数字,但它们是不同的东西。1最后,一旦我们有了一种类型来表示一个概念,它通常会成为一个挂起行为的好地方,引导我们使用更加面向对象的方法,而不是将相关行为分散在代码中。

The more code we write, the more we’re convinced that we should define types to represent value concepts in the domain, even if they don’t do much. It helps to create a consistent domain model that is more self-explanatory. If we create, for example, an Item type in a system, instead of just using String, we can find all the code that’s relevant for a change without having to chase through the method calls. Specific types also reduce the risk of confusion—as the Mars Climate Orbiter disaster showed, feet and metres may both be represented as numbers but they’re different things.1 Finally, once we have a type to represent a concept, it usually turns out to be a good place to hang behavior, guiding us towards using a more object-oriented approach instead of scattering related behavior across the code.

1. 1999 年,美国宇航局的火星气候探测器在火星大气层中烧毁,原因之一是导航软件混淆了公制和英制单位。http ://news.bbc.co.uk/1/hi/sci/tech/514763.stm上有简要说明。

1. In 1999, NASA’s Mars Climate Orbiter burned up in the planet’s atmosphere because, amongst other problems, the navigation software confused metric with imperial units. There’s a brief description at http://news.bbc.co.uk/1/hi/sci/tech/514763.stm.

我们使用三种基本技术来引入值类型,我们将其称为(在头韵中):突破发芽,和捆绑

We use three basic techniques for introducing value types, which we’ve called (in a fit of alliteration): breaking out, budding off, and bundling up.

突破

Breaking out

当我们发现对象中的代码变得复杂时,这通常表明它正在实现多个关注点,我们可以将连贯的行为单元分解为辅助类型。“整理翻译器”(第135页)中有一个例子,我们将处理传入消息的类分成两部分:一部分用于解析消息字符串,另一部分用于解释解析结果。

When we find that the code in an object is becoming complex, that’s often a sign that it’s implementing multiple concerns and that we can break out coherent units of behavior into helper types. There’s an example in “Tidying Up the Translator” (page 135) where we break a class that handles incoming messages into two parts: one to parse the message string, and one to interpret the result of the parsing.

萌芽

Budding off

当我们想在代码中标记一个新的领域概念时,我们通常会引入一个占位符类型,该类型包装单个字段,或者根本没有字段。随着代码的增长,我们通过添加字段和方法来在新类型中填充更多细节。随着我们添加的每种类型,我们都在提高代码的抽象级别。

When we want to mark a new domain concept in the code, we often introduce a placeholder type that wraps a single field, or maybe has no fields at all. As the code grows, we fill in more detail in the new type by adding fields and methods. With each type that we add, we’re raising the level of abstraction of the code.

捆绑销售

Bundling up

当我们注意到一组值总是一起使用时,我们会认为这是缺少构造的暗示。第一步可能是创建一个具有固定公共字段的新类型——只需给该组命名即可突出缺少的概念。稍后我们可以将行为迁移到新的类型,这最终可能允许我们将其字段隐藏在干净的界面后面,满足“组合比各部分之和更简单”的规则。

When we notice that a group of values are always used together, we take that as a suggestion that there’s a missing construct. A first step might be to create a new type with fixed public fields—just giving the group a name highlights the missing concept. Later we can migrate behavior to the new type, which might eventually allow us to hide its fields behind a clean interface, satisfying the “composite simpler than the sum of its parts” rule.

我们发现,发现值类型通常是出于尝试遵循我们的设计原则的动机,而不是为了在编写测试时应对代码压力。

We find that the discovery of value types is usually motivated by trying to follow our design principles, rather than by responding to code stresses when writing tests.

物体从哪里来?

Where Do Objects Come From?

发现对象类型的类别是相似的(这就是我们将它们强行塞入这些名称的原因),只是我们从编写单元测试中获得的设计指导往往更为重要。正如我们在“外部和内部质量”(第10页)中所写,我们利用单元测试的努力来维护代码的内部质量。第 20 章中有更多关于测试对设计影响的示例。

The categories for discovering object types are similar (which is why we shoehorned them into these names), except that the design guidance we get from writing unit tests tends to be more important. As we wrote in “External and Internal Quality” (page 10), we use the effort of unit testing to maintain the code’s internal quality. There are more examples of the influence of testing on design in Chapter 20.

拆分:将大型对象拆分为一组协作对象

Breaking Out: Splitting a Large Object into a Group of Collaborating Objects

当开始编写新的代码领域时,我们可能会暂时搁置设计判断,只是编写代码而不尝试强加太多结构。这使我们能够在该领域获得一些经验,并测试我们对所开发的任何外部 API 的理解。不久之后,我们会发现我们的代码变得太复杂而难以理解,并希望将其清理干净。我们可以开始将内聚的功能单元提取到较小的协作对象中,然后我们可以独立地对其进行单元测试。拆分出新对象还迫使我们查看所提取代码的依赖关系。

When starting a new area of code, we might temporarily suspend our design judgment and just write code without attempting to impose much structure. This allows us to gain some experience in the area and test our understanding of any external APIs we’re developing against. After a short while, we’ll find our code becoming too complex to understand and will want to clean it up. We can start pulling out cohesive units of functionality into smaller collaborating objects, which we can then unit-test independently. Splitting out a new object also forces us to look at the dependencies of the code we’re pulling out.

我们对推迟清理有两个顾虑。第一个是我们应该等多久再做某件事。在时间压力下,我们很容易将非结构化代码保持原样,然后继续做下一件事(“毕竟,它能工作,而且只是一个类……”)。我们已经看到太多意图不明确的代码,而清理的成本在团队最负担不起的时候才开始。第二个顾虑是,有时最好将此代码视为一个峰值— 一旦我们知道该怎么做,只需将其回滚并干净地重新实现。代码并不因为存在而神圣,第二次也不会花那么长时间。

We have two concerns about deferring cleanup. The first is how long we should wait before doing something. Under time pressure, it’s tempting to leave the unstructured code as is and move on to the next thing (“after all, it works and it’s just one class...”). We’ve seen too much code where the intention wasn’t clear and the cost of cleanup kicked in when the team could least afford it. The second concern is that occasionally it’s better to treat this code as a spike—once we know what to do, just roll it back and reimplement cleanly. Code isn’t sacred just because it exists, and the second time won’t take as long.

测试表明...

The Tests Say...

图像

如果对象太大而难以测试,或者测试失败难以解释,则将其拆分。然后分别对新部分进行单元测试。

Break up an object if it becomes too large to test easily, or if its test failures become difficult to interpret. Then unit-test the new parts separately.

展望未来...

Looking Ahead...

图像

在第 12 章中,提取 时AuctionMessageTranslator,我们避免包括其与 的交互,MainWindow因为这样会赋予它太多职责。查看新类的行为,我们发现缺少依赖项 ,AuctionEventListener这是我们在编写单元测试时定义的。我们重新打包 中的现有代码Main以提供新接口的实现。AuctionMessageTranslator满足我们的设计启发式方法:它通过将消息翻译与拍卖显示分开来引入关注点分离,并将消息处理代码抽象为新的特定领域概念。

In Chapter 12, when extracting an AuctionMessageTranslator, we avoid including its interaction with MainWindow because that would give it too many responsibilities. Looking at the behavior of the new class, we identify a missing dependency, AuctionEventListener, which we define while writing the unit tests. We repackage the existing code in Main to provide an implementation for the new interface. AuctionMessageTranslator satisfies both our design heuristics: it introduces a separation of concerns by splitting message translation from auction display, and it abstracts message-handling code into a new domain-specific concept.

萌芽:定义对象需要的新服务并添加新对象来提供该服务

Budding Off: Defining a New Service That an Object Needs and Adding a New Object to Provide It

当代码更加稳定并具有一定程度的结构时,我们经常通过“拉”来发现新类型。我们可能会向对象添加行为,并发现按照我们的设计原则,某些新功能不属于该对象。

When the code is more stable and has some degree of structure, we often discover new types by “pulling” them into existence. We might be adding behavior to an object and find that, following our design principles, some new feature doesn’t belong inside it.

我们的应对措施是创建一个接口,从对象的角度定义对象所需的服务。我们为新行为编写测试,就好像服务已经存在一样,使用模拟对象来帮助描述目标对象与其新协作者之间的关系;这就是我们AuctionEventListener在上一节中提到的引入方法。

Our response is to create an interface to define the service that the object needs from the object’s point of view. We write tests for the new behavior as if the service already exists, using mock objects to help describe the relationship between the target object and its new collaborator; this is how we introduced the AuctionEventListener we mentioned in the previous section.

开发周期如下。在实现一个对象时,我们发现它需要另一个对象提供的服务。我们为新服务命名,并在客户端对象的单元测试中模拟它,以阐明两者之间的关系。然后我们编写一个对象来提供该服务,并在此过程中发现对象需要哪些服务。我们遵循这个协作者关系链(或可能是有向图),直到我们连接到现有对象,无论是我们自己的还是来自第三方 API 的。这就是我们实现“从输入到输出的开发”(第43页)的方式。

The development cycle goes like this. When implementing an object, we discover that it needs a service to be provided by another object. We give the new service a name and mock it out in the client object’s unit tests, to clarify the relationship between the two. Then we write an object to provide that service and, in doing so, discover what services that object needs. We follow this chain (or perhaps a directed graph) of collaborator relationships until we connect up to existing objects, either our own or from a third-party API. This is how we implement “Develop from the Inputs to the Outputs” (page 43).

我们认为这是“按需”设计:我们根据客户的需求“拉”出接口及其实现,而不是“推出”我们认为类应该提供的功能。

We think of this as “on-demand” design: we “pull” interfaces and their implementations into existence from the needs of the client, rather than “pushing” out the features that we think a class should provide.

测试表明...

The Tests Say...

图像

当编写测试时,我们会问自己:“如果这个方法有效,谁会知道?”如果该问题的正确答案不在目标对象中,那么可能是时候引入新的合作者了。

When writing a test, we ask ourselves, “If this worked, who would know?” If the right answer to that question is not in the target object, it’s probably time to introduce a new collaborator.

展望未来...

Looking Ahead...

图像

在第 13 章中,我们引入了一个Auction接口。竞标的概念本来是 的一项额外职责,因此我们引入了一项新的竞标服务——只是一个接口,没有任何实现。我们编写了一个新测试来展示和AuctionSniper之间的关系。然后我们编写了一个具体的实现——最初是 中的一个匿名类,后​​来是。AuctionSniperAuctionAuctionMainXMPPAuction

In Chapter 13, we introduce an Auction interface. The concept of making a bid would have been an additional responsibility for AuctionSniper, so we introduce a new service for bidding—just an interface without any implementation. We write a new test to show the relationship between AuctionSniper and Auction. Then we write a concrete implementation of Auction—initially as an anonymous class in Main, later as XMPPAuction.

捆绑:将相关对象隐藏到包含对象中

Bundling Up: Hiding Related Objects into a Containing Object

这就是“组合比各部分之和更简单”原则的应用(第53页)。当我们拥有一组协同工作的相关对象时,我们可以将它们打包成一个包含对象。新对象将复杂性隐藏在抽象中,使我们能够在更高级别上进行编程。

This is the application of the “composite simpler than the sum of its parts” rule (page 53). When we have a cluster of related objects that work together, we can package them up in a containing object. The new object hides the complexity in an abstraction that allows us to program at a higher level.

将隐含概念具体化的过程还有其他一些好处。首先,我们必须给它起一个名字,这样可以帮助我们更好地理解领域。其次,我们可以更清楚地确定依赖关系的范围,因为我们可以看到概念的边界。第三,我们可以更精确地进行单元测试。我们可以直接测试新的复合对象,并使用模拟实现来简化从中提取它的代码的测试(因为,当然,我们为新对象扮演的角色添加了一个接口)。

The process of making an implicit concept concrete has some other nice effects. First, we have to give it a name which helps us understand the domain a little better. Second, we can scope dependencies more clearly, since we can see the boundaries of the concept. Third, we can be more precise with our unit testing. We can test the new composite object directly, and use a mock implementation to simplify the tests for code from which it was extracted (since, of course, we added an interface for the role the new object plays).

测试表明...

The Tests Say...

图像

当对象的测试变得太复杂而无法设置时(当有太多移动部件无法使代码进入相关状态时),请考虑捆绑一些协作对象。“臃肿的构造函数” (第 238页) 中有一个例子。

When the test for an object becomes too complicated to set up—when there are too many moving parts to get the code into the relevant state—consider bundling up some of the collaborating objects. There’s an example in “Bloated Constructor” (page 238).

展望未来...

Looking Ahead...

图像

在第 17 章中,我们介绍了XMPPAuctionHouse如何打包与消息传递基础结构相关的所有内容,以及SniperLauncher如何构建和附加Sniper。一旦提取,对 Swing 行为的引用就会SniperLauncher显得不合适,因此我们引入了SniperCollector如何解耦域。

In Chapter 17, we introduce XMPPAuctionHouse to package up everything to do with the messaging infrastructure, and SniperLauncher for constructing and attaching a Sniper. Once extracted, the references to Swing behavior in SniperLauncher stand out as inappropriate, so we introduce SniperCollector to decouple the domains.

识别与接口的关系

Identify Relationships with Interfaces

与其他开发人员相比,我们更自由地使用 Java 接口。这反映了我们重视对象之间的关系,正如其通信协议所定义的那样。我们使用接口来命名对象可以扮演的角色并描述它们将接受的消息。

We use Java interfaces more liberally than some other developers. This reflects our emphasis on the relationships between objects, as defined by their communication protocols. We use interfaces to name the roles that objects can play and to describe the messages they’ll accept.

我们还希望接口尽可能窄,即使这意味着我们需要更多接口。接口上的方法越少,它在调用对象中的作用就越明显。我们不必担心哪些其他方法与特定调用相关,哪些方法是为了方便而包含的。窄接口也更容易编写适配器和装饰器;需要实现的东西更少,因此更容易编写可以很好地组合在一起的对象。

We also prefer interfaces to be as narrow as possible, even though that means we need more of them. The fewer methods there are on an interface, the more obvious is its role in the calling object. We don’t have to worry which other methods are relevant to a particular call and which were included for convenience. Narrow interfaces are also easier to write adapters and decorators for; there’s less to implement, so it’s easier to write objects that compose together well.

正如我们在“萌芽”中所描述的那样,“拉”出接口有助于我们尽可能地缩小接口范围。从客户端驱动接口可以避免泄露有关其实现者的过多信息,从而最大限度地减少对象之间的隐式耦合,从而保持代码的可塑性。

“Pulling” interfaces into existence, as we described in “Budding Off,” helps us keep them as narrow as possible. Driving an interface from its client avoids leaking excess information about its implementers, which minimizes any implicit coupling between objects and so keeps the code malleable.

重构接口

Refactor Interfaces Too

一旦我们有了协议的接口,我们就可以开始关注相似之处和不同之处。在一个相当大的代码库中,我们经常会开始发现看起来相似的接口。这意味着我们应该看看它们是否代表一个单一的概念,是否应该合并。提取共同的角色使设计更可塑性更强,因为更多的组件将“可插入兼容”,这样我们就可以在更高的抽象层次上工作。对于开发人员来说,还有一个次要优势,那就是需要花时间去理解的概念会更少。

Once we have interfaces for protocols, we can start to pay attention to similarities and differences. In a reasonably large codebase, we often start to find interfaces that look similar. This means we should look at whether they represent a single concept and should be merged. Extracting common roles makes the design more malleable because more components will be “plug-compatible,” so we can work at a higher level of abstraction. For the developer, there’s a secondary advantage that there will be fewer concepts that cost time to understand.

或者,如果相似的接口最终代表不同的概念,我们可以特意将它们区分开来,这样编译器就可以确保我们只正确地组合对象。决定将外观相似的接口分开是重新考虑其命名的好时机。很可能至少有一个接口有更合适的名称。

Alternatively, if similar interfaces turn out to represent different concepts, we can make a point of making them distinct, so that the compiler can ensure that we only combine objects correctly. A decision to separate similar-looking interfaces is a good time to reconsider their naming. It’s likely that there’s a more appropriate name for at least one of them.

最后,另一个考虑重构接口的时间是当我们开始实现它们时。例如,如果我们发现实现类的结构不清晰,也许它有太多的职责,这可能暗示接口也没有重点,应该拆分。

Finally, another time to consider refactoring interfaces is when we start implementing them. For example, if we find that the structure of an implementing class is unclear, perhaps it has too many responsibilities which might be a hint that the interface is unfocused too and should be split up.

组合对象来描述系统行为

Compose Objects to Describe System Behavior

单元级别的 TDD 指导我们将系统分解为值类型和松散耦合的计算对象。测试让我们很好地了解每个对象的行为方式以及如何将其与其他对象组合。然后,我们使用较低级别的对象作为更强大的对象的构建块;这就是我们在第 2 章中描述的对象网络

TDD at the unit level guides us to decompose our system into value types and loosely coupled computational objects. The tests give us a good understanding of how each object behaves and how it can be combined with others. We then use lower-level objects as the building blocks of more capable objects; this is the web of objects we described in Chapter 2.

例如,在 jMock 中,我们在名为 的上下文对象中汇编了对测试的预期调用的描述Mockery。在测试运行期间,Mockery会将对其模拟对象的任何调用传递给其Expectation,每个 都会尝试匹配该调用。如果Expectation匹配,则测试的该部分成功。如果没有匹配,则每个 都Expectation报告其不一致,测试失败。在运行时,汇编的对象如图7.1所示:

In jMock, for example, we assemble a description of the expected calls for a test in a context object called a Mockery. During a test run, the Mockery will pass calls made to any of its mocked objects to its Expectations, each of which will attempt to match the call. If an Expectation matches, that part of the test succeeds. If none matches, then each Expectation reports its disagreement and the test fails. At runtime, the assembled objects look like Figure 7.1:

图 7.1 jMock Expectation由许多对象组装而成

Figure 7.1 jMock Expectations are assembled from many objects

图像

这种方法的优点是,我们最终会得到一个由相对较少的代码构建而成的灵活应用程序结构。它特别适合于代码必须支持许多相关场景的情况。对于每种场景,我们提供不同的组装组件实际上就是构建一个子系统,将其插入到应用程序的其余部分。此类设计也易于扩展 — 只需编写一个新的可插入的组件并将其添加进去即可;您将在第III 部分中看到我们编写了几个新的 Hamcrest 匹配器。

The advantage of this approach is that we end up with a flexible application structure built from relatively little code. It’s particularly suitable where the code has to support many related scenarios. For each scenario, we provide a different assembly of components to build, in effect, a subsystem to plug into the rest of the application. Such designs are also easy to extend—just write a new plug-compatible component and add it in; you’ll see us write several new Hamcrest matchers in Part III.

例如,为了让 jMock 检查某个方法example.doSomething()是否只被调用一次并且参数类型为String,我们像这样设置测试上下文:

For example, to have jMock check that a method example.doSomething() is called exactly once with an argument of type String, we set up our test context like this:

InvocationExpectation 期望 = 新的 InvocationExpectation();

期望.setParametersMatcher(

新的 AllParametersMatcher(Arrays.asList(新的 IsInstanceOf(String.class)));

期望.setCardinality(新的 Cardinality(1,1));

期望.setMethodMatcher(新的 MethodNameMatcher(“doSomething”));

期望.setObjectMatcher(新的 IsSame<Example>(示例));



上下文.addExpectation(期望);

InvocationExpectation expectation = new InvocationExpectation();

expectation.setParametersMatcher(

new AllParametersMatcher(Arrays.asList(new IsInstanceOf(String.class)));

expectation.setCardinality(new Cardinality(1, 1));

expectation.setMethodMatcher(new MethodNameMatcher("doSomething"));

expectation.setObjectMatcher(new IsSame<Example>(example));



context.addExpectation(expectation);

构建更高级的编程

Building Up to Higher-Level Programming

您可能已经发现上述代码片段存在一个问题:它没有很好地解释期望测试的内容。从概念上讲,组装对象网络很简单。不幸的是,我们通常使用的主流语言将我们关心的信息(对象及其关系)埋藏在关键字、设置器、标点符号等的泥潭中。仅分配和链接对象(如本例所示)并不能帮助我们理解我们正在组装的系统的行为——它不能表达我们的意图2

You have probably spotted a difficulty with the code fragment above: it doesn’t explain very well what the expectation is testing. Conceptually, assembling a web of objects is straightforward. Unfortunately, the mainstream languages we usually work with bury the information we care about (objects and their relationships) in a morass of keywords, setters, punctuation, and the like. Just assigning and linking objects, as in this example, doesn’t help us understand the behavior of the system we’re assembling—it doesn’t express our intent.2

2 . 将对象构造移至单独的 XML 文件的常见替代方法也不行。

2. Nor does the common alternative of moving the object construction into a separate XML file.

我们的应对措施是将代码组织成两层:实现层是对象的图形,其行为是其对象如何响应事件的综合结果;声明层在实现层中构建对象,使用小型“糖”方法和语法来描述每个片段的用途。声明层描述代码将做什么,而实现层描述代码如何做。声明层实际上是嵌入(在本例中)Java 中的小型领域特定语言。3

Our response is to organize the code into two layers: an implementation layer which is the graph of objects, its behavior is the combined result of how its objects respond to events; and, a declarative layer which builds up the objects in the implementation layer, using small “sugar” methods and syntax to describe the purpose of each fragment. The declarative layer describes what the code will do, while the implementation layer describes how the code does it. The declarative layer is, in effect, a small domain-specific language embedded (in this case) in Java.3

3.我们在使用 jMock 时就意识到了这一点。我们在[Freeman06]中写下了我们的经验。

3. This became clear to us when working on jMock. We wrote up our experiences in [Freeman06].

这两个层的目的不同,这意味着我们对每个层使用不同的编码风格。对于实现层,我们坚持上一章中描述的传统面向对象风格指南。对于声明层,我们更加灵活——我们甚至可能使用“火车残骸”方法调用链或静态方法来帮助传达要点。

The different purposes of the two layers mean that we use a different coding style for each. For the implementation layer we stick to the conventional object-oriented style guidelines we described in the previous chapter. We’re more flexible for the declarative layer—we might even use “train wreck” chaining of method calls or static methods to help get the point across.

jMock 本身就是一个很好的例子。我们可以将上一节中的示例重写为:

A good example is jMock itself. We can rewrite the example from the previous section as:

图像

对象Expectations是一个构造期望的Builder [Gamma94]。它定义了“糖”方法,用于构造期望和匹配器的组合并将其加载到中,如图7.2Mockery所示。

The Expectations object is a Builder [Gamma94] that constructs expectations. It defines “sugar” methods that construct the assembly of expectations and matchers and load it into the Mockery, as shown in Figure 7.2.

图 7.2 语法层构成解释器

Figure 7.2 A syntax-layer constructs the interpreter

图像

大多数时候,这样的声明层都来自持续的“无情”重构。我们从编写直接组成对象的代码开始,然后不断剔除重复部分。我们还添加辅助方法,将语法噪音从代码主体中剔除,并添加解释。注意代码的某个区域是否清晰,我们会添加或移动结构,直到清晰为止;这在现代重构 IDE 中非常容易做到。最终,我们发现我们有了两层结构。有时,我们从想要的声明性代码开始,然后向下填充它的实现,就像我们在第 10 章中的第一个端到端测试中所做的那样。

Most of the time, such a declarative layer emerges from continual “merciless” refactoring. We start by writing code that directly composes objects and keep factoring out duplication. We also add helper methods to push the syntax noise out of the main body of the code and to add explanation. Taking care to notice when an area of code is not clear, we add or move structure until it is; this is very easy to do in a modern refactoring IDE. Eventually, we find we have our two-layer structure. Occasionally, we start from the declarative code we’d like to have and work down to fill in its implementation, as we do with the first end-to-end test in Chapter 10.

我们的目的最终是用更少的代码实现更多功能。我们希望从控制流和数据操作方面的编程提升到由较小的程序(其中对象构成最小的行为单位)编写程序。这些都不是新东西——它与通过使用管道编写实用程序来编程 Unix 的概念相同[Kernighan76]4或者在 Lisp 中构建语言层[Graham93] ——但我们在该领域见到它的频率仍然不如我们所愿。

Our purpose, in the end, is to achieve more with less code. We aspire to raise ourselves from programming in terms of control flow and data manipulation, to composing programs from smaller programs—where objects form the smallest unit of behavior. None of this is new—it’s the same concept as programming Unix by composing utilities with pipes [Kernighan76],4 or building up layers of language in Lisp [Graham93]—but we still don’t see it in the field as often as we would like.

4 . Kernighan 和 Plauger 将管道的概念归功于 Douglas McIlroy,后者于 1964 年撰写了一份备忘录,其中提出了数据通过分段花园软管传输的比喻。目前,该备忘录可在http://plan9.bell-labs.com/who/dmr/mdmpipe.pdf上找到。

4. Kernighan and Plauger attribute the idea of pipes to Douglas McIlroy, who wrote a memo in 1964 suggesting the metaphor of data passing through a segmented garden hose. It’s currently available at http://plan9.bell-labs.com/who/dmr/mdmpipe.pdf.

那么课程又如何呢?

And What about Classes?

最后一点。对于一本关于面向对象软件的书来说,我们很少谈论类和继承,这很不寻常。现在应该很明显,我们一直在将应用程序领域推向对象、通信协议之间的空隙。我们强调接口多于类,因为这是其他对象所看到的:对象的类型由其扮演的角色定义。

One last point. Unusually for a book on object-oriented software, we haven’t said much about classes and inheritance. It should be obvious by now that we’ve been pushing the application domain into the gaps between the objects, the communication protocols. We emphasize interfaces more than classes because that’s what other objects see: an object’s type is defined by the roles it plays.

我们将对象的类视为“实现细节”——一种实现类型的方式,而不是类型本身。我们通过分解常见行为来发现对象类层次结构,但如果可能的话,我们更愿意重构委托,因为我们发现它使我们的代码更灵活、更易于理解。5另一方面,值类型不太可能使用委托,因为它们没有对等体。

We view classes for objects as an “implementation detail”—a way of implementing types, not the types themselves. We discover object class hierarchies by factoring out common behavior, but prefer to refactor to delegation if possible since we find that it makes our code more flexible and easier to understand.5 Value types, on the other hand, are less likely to use delegation since they don’t have peers.

5.当然,在能够很好地支持多重继承的语言中,设计力量是不同的,例如 Eiffel [Meyer91]

5. The design forces, of course, are different in languages that support multiple inheritance well, such as Eiffel [Meyer91].

有很多关于如何使用课程的好建议,例如,[Fowler99][Kerievsky04][Evans03]

There’s plenty of good advice on how to work with classes in, for example, [Fowler99], [Kerievsky04], and [Evans03].

第 8 章 基于第三方代码的构建

Chapter 8. Building on Third-Party Code

当今的编程就是对你必须处理的部分进行科学研究。

Programming today is all about doing science on the parts you have to work with.

—杰拉尔德·杰伊·萨斯曼

—Gerald Jay Sussman

介绍

Introduction

我们已经展示了如何将系统设计付诸实践:发现对象需要什么,然后编写接口和其他对象来满足这些需求。这个过程对于新功能非常有效。然而,在某个时候,我们的设计会遇到第三方代码最能满足的需求:标准 API、开源库或供应商产品。第三方代码的关键点在于我们无法控制它,因此我们不能使用我们的流程来指导其设计。相反,我们必须专注于我们的设计与外部代码之间的集成。

We’ve shown how we pull a system’s design into existence: discovering what our objects need and writing interfaces and further objects to meet those needs. This process works well for new functionality. At some point, however, our design will come up against a need that is best met by third-party code: standard APIs, open source libraries, or vendor products. The critical point about third-party code is that we don’t control it, so we cannot use our process to guide its design. Instead, we must focus on the integration between our design and the external code.

在集成中,我们需要实现一个抽象,这是我们在开发其余功能时发现的。由于第三方 API 对我们的设计产生了影响,我们必须在优雅和实际使用他人的想法之间找到最佳平衡。我们必须检查我们是否正确使用了第三方 API,如果发现我们的假设不正确,则调整我们的抽象以适应。

In integration, we have an abstraction to implement, discovered while we developed the rest of the feature. With the third-party API pushing back at our design, we must find the best balance between elegance and practical use of someone else’s ideas. We must check that we are using the third-party API correctly, and adjust our abstraction to fit if we find that our assumptions are incorrect.

仅模拟您拥有的类型

Only Mock Types That You Own

不要模拟无法改变的类型

Don’t Mock Types You Can’t Change

当我们使用第三方代码时,我们通常无法深入了解其工作原理。即使我们有源代码,我们也很少有时间彻底阅读它以探索其所有怪癖。我们可以阅读其文档,但这些文档通常不完整或不正确。该软件也可能存在需要我们解决的错误。因此,尽管我们知道我们希望抽象如何运行,但直到我们结合第三方代码对其进行测试时,我们才知道它是否真的如此。

When we use third-party code we often do not have a deep understanding of how it works. Even if we have the source available, we rarely have time to read it thoroughly enough to explore all its quirks. We can read its documentation, which is often incomplete or incorrect. The software may also have bugs that we will need to work around. So, although we know how we want our abstraction to behave, we don’t know if it really does so until we test it in combination with the third-party code.

我们也不想更改第三方代码,即使我们有源代码。每次有新版本时都应用私有补丁通常太麻烦了。如果我们不能更改 API,那么我们就无法响应通过编写涉及它的单元测试获得的任何设计反馈。无论单元测试发出什么警报可能会有人抱怨外部 API 的尴尬,但我们必须忍受它。

We also prefer not to change third-party code, even when we have the sources. It’s usually too much trouble to apply private patches every time there’s a new version. If we can’t change an API, then we can’t respond to any design feedback we get from writing unit tests that touch it. Whatever alarm bells the unit tests might be ringing about the awkwardness of an external API, we have to live with it as it stands.

这意味着,在对调用第三方类型的对象进行单元测试时,提供第三方类型的模拟实现用处有限。我们发现,模拟外部库的测试通常需要很复杂,才能使代码处于我们需要执行的功能的正确状态。此类测试中的混乱告诉我们设计不正确,但我们不必通过改进代码来解决问题,而是必须在代码和测试中承担额外的复杂性。

This means that providing mock implementations of third-party types is of limited use when unit-testing the objects that call them. We find that tests that mock external libraries often need to be complex to get the code into the right state for the functionality we need to exercise. The mess in such tests is telling us that the design isn’t right but, instead of fixing the problem by improving the code, we have to carry the extra complexity in both code and test.

第二个风险是我们必须确保我们存根或模拟的行为与外部库实际执行的行为相匹配。这有多难取决于库的质量——它是否被指定(和实现)得足够好,让我们确信我们的单元测试是有效的。即使我们一次做对了,我们也必须确保测试在升级库时仍然有效。

A second risk is that we have to be sure that the behavior we stub or mock matches what the external library will actually do. How difficult this is depends on the quality of the library—whether it’s specified (and implemented) well enough for us to be certain that our unit tests are valid. Even if we get it right once, we have to make sure that the tests remain valid when we upgrade the libraries.

编写适配器层

Write an Adapter Layer

如果我们不想模拟外部 API,我们如何测试驱动它的代码?我们将使用 TDD 来设计对象所需服务的接口 — 这些接口将根据对象的域而不是外部库来定义。

If we don’t want to mock an external API, how can we test the code that drives it? We will have used TDD to design interfaces for the services our objects need—which will be defined in terms of our objects’ domain, not the external library.

我们编写了一层适配器对象(如[Gamma94]中所述),使用第三方 API 来实现这些接口,如图8.1所示。我们尽可能保持这一层薄,以尽量减少可能脆弱且难以测试的代码量。我们用有针对性的集成测试来测试这些适配器,以确认我们对第三方 API 工作原理的理解。与单元测试的数量相比,集成测试相对较少,因此即使它们不如内存单元测试快,也不会妨碍构建。

We write a layer of adapter objects (as described in [Gamma94]) that uses the third-party API to implement these interfaces, as in Figure 8.1. We keep this layer as thin as possible, to minimize the amount of potentially brittle and hard-to-test code. We test these adapters with focused integration tests to confirm our understanding of how the third-party API works. There will be relatively few integration tests compared to the number of unit tests, so they should not get in the way of the build even if they’re not as fast as the in-memory unit tests.

图 8.1 可模拟适配器到第三方对象

Figure 8.1 Mockable adapters to third-party objects

图像

遵循这种方法始终如一地生成一组接口,以应用程序的术语定义应用程序与世界其他部分之间的关​​系,并阻止低级技术概念的泄露进入应用程序领域模型。在第 25 章中,我们讨论了一个常见示例,其中使用持久性 API 实现应用程序领域模型中的抽象。

Following this approach consistently produces a set of interfaces that define the relationship between our application and the rest of the world in our application’s terms and discourages low-level technical concepts from leaking into the application domain model. In Chapter 25, we discuss a common example where abstractions in the application’s domain model are implemented using a persistence API.

在某些情况下,模拟第三方库可能会有所帮助。我们可能会使用模拟来模拟难以用真实库触发的行为,例如抛出异常。同样,我们可能会使用模拟来测试一系列调用,例如确保在发生故障时回滚事务。测试套件中不应该有太多这样的测试。

There are some exceptions where mocking third-party libraries can be helpful. We might use mocks to simulate behavior that is hard to trigger with the real library, such as throwing exceptions. Similarly, we might use mocks to test a sequence of calls, for example making sure that a transaction is rolled back if there’s a failure. There should not be many tests like this in a test suite.

这种模式不适用于值类型,因为我们当然不需要模拟它们。但是,我们仍然必须做出设计决策,决定在代码中使用第三方值类型的程度。它们可能太过基础,我们只需直接使用它们即可。但是,我们通常希望遵循与第三方服务相同的隔离原则,并在适用于应用程序域和外部域的值类型之间进行转换。

This pattern does not apply to value types because, of course, we don’t need to mock them. We still, however, have to make design decisions about how much to use third-party value types in our code. They might be so fundamental that we just use them directly. Often, however, we want to follow the same principles of isolation as for third-party services, and translate between value types appropriate to the application domain and to the external domain.

在集成测试中模拟应用程序对象

Mock Application Objects in Integration Tests

如上所述,适配器对象是被动的,对我们代码的调用做出反应。有时,适配器对象必须回调应用程序的对象。例如,基于事件的库通常期望客户端提供回调对象,以便在事件发生时得到通知。在这种情况下,应用程序代码将为适配器提供自己的事件回调(根据应用程序域定义)。然后,适配器将适配器回调传递给外部库以接收外部事件并将其转换为应用程序回调。

As described above, adapter objects are passive, reacting to calls from our code. Sometimes, adapter objects must call back to objects from the application. Event-based libraries, for example, usually expect the client to provide a callback object to be notified when an event happens. In this case, the application code will give the adapter its own event callback (defined in terms of the application domain). The adapter will then pass an adapter callback to the external library to receive external events and translate them for the application callback.

在这些情况下,我们在测试与第三方代码集成的对象时确实使用模拟对象 - 但仅用于模拟应用程序中定义的回调接口,以验证适配器是否正确地在域之间转换事件(图 8.2)。

In these cases, we do use mock objects when testing objects that integrate with third-party code—but only to mock the callback interfaces defined in the application, to verify that the adapter translates events between domains correctly (Figure 8.2).

图 8.2 在集成测试中使用模拟对象

Figure 8.2 Using mock objects in integration tests

图像

多线程使集成测试变得更加复杂。例如,第三方库可能会启动后台线程来将事件传递给应用程序代码,因此同步是适配器层设计工作的一个重要方面;我们将在第 26 章中进一步讨论这一点。

Multithreading adds more complication to integration tests. For example, third-party libraries may start background threads to deliver events to the application code, so synchronization is a vital aspect of the design effort of adapter layers; we discuss this further in Chapter 26.

第三部分 一个实例

Part III. A Worked Example

我们编写本书的目标之一是传达测试驱动软件开发的完整体验。我们希望展示这些技术如何在比书中通常介绍的示例更大的范围内结合在一起。我们特意包含外部组件,在本例中是 Swing 和消息传递基础结构,因为这种方法的压力点通常位于我们拥有的代码和我们必须使用的代码之间的边界。我们构建的应用程序包括基于事件的设计、多线程和分布等复杂性。

One of our goals in writing this book was to convey the whole experience of test-driven software development. We want to show how the techniques fit together over a larger scale than the examples usually presented in books. We make a point of including external components, in this case Swing and messaging infrastructure, since the stress points of this kind of approach are usually at the boundaries between code that we own and code that we have to work with. The application that we build includes such complexities as event-based design, multiple threads, and distribution.

另一个目标是讲述一个真实的故事,所以我们加入了一些情节,在这些情节中,我们必须回溯那些最终被证明是错误的决定。这种情况发生在我们所见过的任何软件开发中。即使是最优秀的人也会误解需求和技术,或者有时只是错过了重点。一个有弹性的过程允许出现错误,并包括尽早发现和恢复错误的技术。毕竟,唯一的选择是将问题留在代码中,通常它们会在以后造成更昂贵的损失。

Another goal was to tell a realistic story, so we include episodes where we have to backtrack on decisions that turn out to be wrong. This happens in any software development that we’ve seen. Even the best people misunderstand requirements and technologies or, sometimes, just miss the point. A resilient process allows for mistakes and includes techniques for discovering and recovering from errors as early as possible. After all, the only alternative is to leave the problems in the code where, generally, they will cause more expensive damage later.

最后,我们想强调我们的渐进式开发文化。经验丰富的团队可以学会以小而安全的步骤对代码进行重大更改。对于那些不习惯的人来说,渐进式更改可能会感觉花费的时间太长。但我们经常被大型重组所困扰,这些重组迷失了方向,最终花费的时间更长——这是不可预测的。通过保持系统始终清洁并始终正常工作,我们可以专注于眼前的即时更改(而不必同时维护所有代码的心理模型),并且合并更改永远不会成为危机。

Finally, we wanted to emphasize our culture of very incremental development. Experienced teams can learn to make substantial changes to their code in small, safe steps. To those not used to it, incremental change can feel as if it takes too long. But we’ve been burned too often by large restructurings that lose their way and end up taking longer—unpredictably so. By keeping the system always clean and always working, we can focus on just the immediate change at hand (instead of having to maintain a mental model of all the code at once), and merging changes back in is never a crisis.

关于格式

On formatting

图像

本例中的一些代码和输出布局看起来有点奇怪。我们不得不修剪和换行较长的行,以使其适合打印页面。在我们的开发环境中,我们使用更长的行长,这(我们认为)使代码布局更具可读性。

Some of the code and output layout in this example looks a bit odd. We’ve had to trim and wrap the long lines to make them fit on the printed page. In our development environments we use a longer line length, which (we think) makes for more readable layout of the code.

第 9 章 委托拍卖狙击手

Chapter 9. Commissioning an Auction Sniper

从头开始

To Begin at the Beginning

我们受委托开发一款自动竞拍应用程序。我们概述了该应用程序的工作原理以及主要组件。我们制定了应用程序逐步发展的基本计划。

In which we are commissioned to build an application that automatically bids in auctions. We sketch out how it should work and what the major components should be. We put together a rough plan for the incremental steps in which we will grow the application.

我们是 Markup and Gouge 的开发团队,该公司在专业市场上购买古董,然后以“最佳品味”的价格卖给客户。Markup and Gouge 一直关注着这个行业,现在很多购买都是在网上进行的,主要从 Southabee's 那里购买,这是一家热衷于在线发展的著名拍卖行。问题是,我们的买家花费大量时间手动检查拍卖状态以决定是否出价,甚至因为反应不够快而错过了几件有吸引力的物品。

We’re a development team for Markup and Gouge, a company that buys antiques on the professional market to sell to clients “with the best possible taste.” Markup and Gouge has been following the industry and now does a lot of its buying online, largely from Southabee’s, a venerable auction house that is keen to grow online. The trouble is that our buyers are spending a lot of their time manually checking the state of an auction to decide whether or not to bid, and even missed a couple of attractive items because they could not respond quickly enough.

经过激烈的讨论,管理层决定委托开发一款“拍卖狙击手”应用,该应用可以监控在线拍卖,并在价格发生变化时自动提高出价,直到达到止损价或拍卖结束。买家们都渴望拥有这款新应用,其中一些买家同意帮助我们明确要开发什么。

After intense discussion, the management decides to commission an Auction Sniper, an application that watches online auctions and automatically bids slightly higher whenever the price changes, until it reaches a stop-price or the auction closes. The buyers are keen to have this new application and some of them agree to help us clarify what to build.

我们首先与买家小组讨论他们的想法,发现为了避免混淆,我们需要就一些基本条款达成一致:•物品是可以识别和购买的东西。

We start by talking through their ideas with the buyers’ group and find that, to avoid confusion, we need to agree on some basic terms: • Item is something that can be identified and bought.

竞标者是有兴趣购买某件物品的个人或组织。

Bidder is a person or organization that is interested in buying an item.

出价是投标人愿意为某物品支付特定价格的声明。

Bid is a statement that a bidder will pay a given price for an item.

当前价格是该物品的当前最高出价。

Current price is the current highest bid for the item.

终止价是竞标者愿意为某件物品支付的最高价格。

Stop price is the most a bidder is prepared to pay for an item.

拍卖是管理物品出价的过程。

Auction is a process for managing bids for an item.

拍卖行是举办拍卖会的机构。

Auction house is an institution that hosts auctions.

讨论产生了一长串的要求,例如能够竞标相关项目组。没有人能在有用的时间内交付所有东西,所以我们讨论了各种选择,买家勉强同意他们宁愿先让一个基本的应用程序运行起来。一旦这一点到位,我们就可以让它更强大。

The discussions generate a long list of requirements, such as being able to bid for related groups of items. There’s no way anyone could deliver everything within a useful time, so we talk through the options and the buyers reluctantly agree that they’d rather get a basic application working first. Once that’s in place, we can make it more powerful.

事实证明,在线系统中每件商品都有拍卖,因此我们决定使用商品标识符来引用其拍卖。实际上,Sniper 应用程序不必关心管理我们购买的任何商品,因为其他系统将处理付款和交付。

It turns out that in the online system there’s an auction for every item, so we decide to use an item’s identifier to refer to its auction. In practice, it also turns out that the Sniper application doesn’t have to concern itself with managing any items we’ve bought, since other systems will handle payment and delivery.

我们决定将拍卖狙击手构建为 Java Swing 应用程序。它将在桌面上运行,并允许用户一次竞标多个物品。它将显示其正在竞标的每个物品的标识符、止损价以及当前拍卖价格和状态。买家将能够通过用户界面添加要竞标的新物品,并且显示值将根据拍卖行收到的事件而变化。买家仍在与我们的可用性人员合作,但我们已经同意了一个粗略的版本,如图 9.1所示。

We decide to build the Auction Sniper as a Java Swing application. It will run on a desktop and allow the user to bid for multiple items at a time. It will show the identifier, stop price, and the current auction price and status for each item it’s sniping. Buyers will be able to add new items for sniping through the user interface, and the display values will change in response to events arriving from the auction house. The buyers are still working with our usability people, but we’ve agreed a rough version that looks like Figure 9.1.

图 9.1 第一个用户界面

Figure 9.1 A first user interface

图像

这显然是不完整且不漂亮的,但它已经足够接近让我们开始。

This is obviously incomplete and not pretty, but it’s close enough to get us started.

在进行这些讨论的同时,我们还与 Southabee 的在线服务支持技术人员进行了交谈。他们向我们发送了一份文件,其中描述了他们在拍卖中竞标的协议,该协议使用XMPP(Jabber)作为其底层通信层。图 9.2显示了它如何处理多个竞标者通过 XMPP 向拍卖行发送出价的情况,我们的狙击手就是其中之一。随着拍卖的进行,Southabee 将向所有连接的竞标者发送事件,以告知他们何时有人的出价提高了当前价格以及拍卖何时结束。

While these discussions are taking place, we also talk to the technicians at Southabee’s who support their online services. They send us a document that describes their protocol for bidding in auctions, which uses XMPP (Jabber) for its underlying communication layer. Figure 9.2 shows how it handles multiple bidders sending bids over XMPP to the auction house, our Sniper being one of them. As the auction progresses, Southabee’s will send events to all the connected bidders to tell them when anyone’s bid has raised the current price and when the auction closes.

图 9.2 Southabee 的在线拍卖系统

Figure 9.2 Southabee’s online auction system

图像

与拍卖行沟通

Communicating with an Auction

拍卖协议

The Auction Protocol

竞拍者和拍卖行之间的消息协议很简单。竞拍者发送命令,命令可以是:Join

The protocol for messages between a bidder and an auction house is simple. Bidders send commands, which can be: Join

竞标者参加拍卖。XMPP 消息的发送者标识竞标者,聊天会话的名称标识物品。

A bidder joins an auction. The sender of the XMPP message identifies the bidder, and the name of the chat session identifies the item.

Bid

Bid

竞标者向拍卖会发送竞标价格。

A bidder sends a bidding price to the auction.

拍卖会发送事件,这些事件可以是:

Auctions send events, which can be:

Price

Price

拍卖会报告当前接受的价格。此事件还包括下一个出价必须提高的最低增量,以及出价此价格的投标人的姓名。拍卖会在投标人加入时向其发送此事件,并在接受新出价时向所有投标人发送此事件。

An auction reports the currently accepted price. This event also includes the minimum increment that the next bid must be raised by, and the name of bidder who bid this price. The auction will send this event to a bidder when it joins and to all bidders whenever a new bid has been accepted.

Close

Close

一场拍卖会宣布结束。上一次价格活动的获胜者赢得了此次拍卖。

An auction announces that it has closed. The winner of the last price event has won the auction.

我们花了一些时间研究文档并与 Southabee 的在线支持人员交谈,并找出一个状态机来显示狙击手可以进行的转换。本质上,狙击手加入拍卖,然后进行几轮竞价,直到拍卖结束,此时狙击手将获胜失败见图 9.3 。为了简单起见,我们暂时省略了止损价;它将在第 18 章中出现。

We spend some time working through the documentation and talking to Southabee’s On-Line support people, and figure out a state machine that shows the transitions a Sniper can make. Essentially, a Sniper joins an auction, then there are some rounds of bidding, until the auction closes, at which point the Sniper will have won or lost; see Figure 9.3. We’ve left out the stop price for now to keep things simple; it’ll turn up in Chapter 18.

图 9.3 以状态机表示的投标人行为

Figure 9.3 A bidder’s behavior represented as a state machine

图像

XMPP 消息

The XMPP Messages

Southabee 的 On-Line 还向我们发送了他们在 XMPP 消息中使用的格式的详细信息。它们非常简单,因为它们只涉及几个名称和值,并且使用键/值对在一行中序列化。每行都以协议本身的版本号开头。消息如下所示:单击此处查看代码图像

Southabee’s On-Line has also sent us details of the formats they use within the XMPP messages. They’re pretty simple, since they only involve a few names and values, and are serialized in a single line with key/value pairs. Each line starts with a version number for the protocol itself. The messages look like this: Click here to view code image

SOLVersion:1.1;命令:JOIN;

SOLVersion:1.1;事件:PRICE;当前价格:192;增量:7;竞标者:其他人;

SOLVersion:1.1;命令:BID;价格:199;

SOLVersion:1.1;事件:CLOSE;Southabee 的在线使用登录名来识别待售物品,因此要竞标带有标识符的物品12793,客户将开始与 Southabee 服务器上的“用户”聊天auction-12793。假设帐户已事先设置,服务器可以根据呼叫者的身份判断谁在竞标。

SOLVersion: 1.1; Command: JOIN;

SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7; Bidder: Someone else;

SOLVersion: 1.1; Command: BID; Price: 199;

SOLVersion: 1.1; Event: CLOSE; Southabee’s On-Line uses login names to identify items for sale, so to bid for an item with identifier 12793, a client would start a chat with the “user” auction-12793 at the Southabee’s server. The server can tell who is bidding from the identity of the caller, assuming the accounts have been set up beforehand.

安全到达

Getting There Safely

即使像这样的小应用程序也太大了,无法一次性编写完成,因此我们需要大致确定实现目标可能采取的步骤。增量开发的一个关键技术是学习如何将功能分割开来,以便可以一次构建一点。每个部分都应该足够重要和具体,以便团队可以知道它何时完成,并且足够小,可以专注于一个概念并可以快速实现。将我们的工作分成小而连贯的部分也有助于我们管理开发风险。我们会定期收到有关我们进展的具体反馈,因此我们可以随着团队发现更多有关领域和技术的信息而调整计划。

Even a small application like this is too large to write in one go, so we need to figure out, roughly, the steps we might take to get there. A critical technique with incremental development is learning how to slice up the functionality so that it can be built a little at a time. Each slice should be significant and concrete enough that the team can tell when it’s done, and small enough to be focused on one concept and achievable quickly. Dividing our work into small, coherent chunks also helps us manage the development risk. We get regular, concrete feedback on the progress we’re making, so we can adjust our plan as the team discovers more about the domain and the technologies.

我们的当务之急是找出狙击手应用程序的一系列增量开发步骤。第一个步骤绝对是我们能够构建的最小功能,即我们在“首先,测试行走骨架”(第32页)中描述的“行走骨架”。在这里,骨架将通过 Swing、XMPP 和我们的应用程序切割出一条最小路径;这足以表明我们可以将这些组件连接在一起。每个后续步骤都会在之前完成的工作的基础上为现有应用程序增加一个复杂性元素。经过一番讨论,我们提出了要构建的功能顺序:单品:加入,不竞标则失败

Our immediate task is to figure out a series of incremental development steps for the Sniper application. The first is absolutely the smallest feature we can build, the “walking skeleton” we described in “First, Test a Walking Skeleton” (page 32). Here, the skeleton will cut a minimum path through Swing, XMPP, and our application; it’s just enough to show that we can plug these components together. Each subsequent step adds a single element of complexity to the existing application, building on the work that’s done before. After some discussion, we come up with this sequence of features to build: Single item: join, lose without bidding

这是我们整合核心基础设施的起始案例;它是第 10 章的主题。

This is our starting case where we put together the core infrastructure; it is the subject of Chapter 10.

单项:加入、竞标和失败

Single item: join, bid, and lose

在基本连接中添加竞标。

Add bidding to the basic connectivity.

单品:加入、竞标、获胜

Single item: join, bid, and win

区分谁发送了中标信息。

Distinguish who sent the winning bid.

显示价格详情

Show price details

开始填写用户界面。

Start to fill out the user interface.

多个项目

Multiple items

支持在同一个应用程序中对多个物品进行竞标。

Support bidding for multiple items in the same application.

通过用户界面添加项目

Add items through the user interface

通过用户界面实现输入。

Implement input via the user interface.

以止损价停止竞标

Stop bidding at the stop price

狙击算法更加智能。

More intelligence in the Sniper algorithm.

在列表中,买家优先考虑用户界面而不是止损价,部分原因是他们想确保自己对该应用程序感到满意,部分原因是如果没有用户界面,就无法轻松地添加多个商品,每个商品都有自己的止损价。

Within the list, the buyers have prioritized the user interface over the stop price, partly because they want to make sure they’ll feel comfortable with the application and partly because there won’t be an easy way to add multiple items, each with its own stop price, without a user interface.

一旦稳定下来,我们就可以处理更复杂的情况,例如,如果出价失败则重试,或者使用不同的出价策略。目前,实现这些功能应该会让我们忙个不停。

Once this is stable, we can work on more complicated scenarios, such as retrying if a bid failed or using different strategies for bidding. For now, implementing just these features should keep us busy.

我们不知道这是否就是我们要采取的步骤的确切顺序,但我们相信我们需要所有这些,而且我们可以在进行过程中进行调整。为了保持专注,我们将计划写在索引卡上,如图9.4所示。

We don’t know if this is exactly the order of steps we’ll take, but we believe we need all of this, and we can adjust as we go along. To keep ourselves focused, we’ve written the plan on an index card, as in Figure 9.4.

图 9.4 初始计划

Figure 9.4 The initial plan

图像

这不是真的

This Isn’t Real

现在,您可能会对我们跳过的所有实际问题提出异议。我们也看到了。我们在流程和设计上采取了捷径,让您在不超出书本内容的情况下感受到真实项目的运作方式。特别是: •这不是现实的架构: XMPP 既不可靠也不安全,因此不适合交易。确保任何这些品质都不在我们的范围之内。话虽如此,我们描述的基本技术仍然适用,无论底层架构是什么。(在我们的辩护中,我们看到主要系统都是建立在像 HTTP 这样不合适的协议上的,所以也许我们并不像我们担心的那样不切实际。) •这不是敏捷规划:我们匆忙完成了项目规划以制作单个待办事项列表。在实际项目中,我们可能会在开始之前对整个可交付成果(发布计划)有一个了解。其他书籍(如[Shore07][Cohn05])对如何进行敏捷规划有很好的描述。

By now you may be raising objections about all the practicalities we’ve skipped over. We saw them too. We’ve taken shortcuts with the process and design to give you a feel of how a real project works while remaining within the limits of a book. In particular: • This isn’t a realistic architecture: XMPP is neither reliable nor secure, and so is unsuitable for transactions. Ensuring any of those qualities is outside our scope. That said, the fundamental techniques that we describe still apply whatever the underlying architecture may be. (In our defense, we see that major systems have been built on a protocol as inappropriate as HTTP, so perhaps we’re not as unrealistic as we fear.) • This isn’t Agile Planning: We rushed through the planning of the project to produce a single to-do list. In a real project, we’d likely have a view of the whole deliverable (a release plan) before jumping in. There are good descriptions of how to do agile planning in other books, such as [Shore07] and [Cohn05].

这不是现实的可用性设计:良好的用户体验设计会调查最终用户真正想要实现的目标,并利用这一点来创造一致的体验。用户体验社区已经与敏捷开发社区合作了一段时间,探讨如何迭代地做到这一点。这个项目很简单,我们可以起草一个我们想要实现的愿景,并朝着这个愿景努力。

This isn’t realistic usability design: Good user experience design investigates what the end user is really trying to achieve and uses that to create a consistent experience. The User Experience community has been engaging with the Agile Development community for some time on how to do this iteratively. This project is simple enough that we can draft a vision of what we want to achieve and work towards it.

第 10 章 行走的骷髅

Chapter 10. The Walking Skeleton

我们设置了开发环境并编写了第一个端到端测试。我们做出了一些基础架构选择,以便我们能够开始构建。我们再次惊讶于这需要付出多少努力。

In which we set up our development environment and write our first end-to-end test. We make some infrastructure choices that allow us to get started, and construct a build. We’re surprised, yet again, at how much effort this takes.

把骷髅从衣柜里拿出来

Get the Skeleton out of the Closet

现在我们已经知道要构建什么了,我们可以继续编写我们的第一个单元测试吗?

So now we’ve got an idea of what to build, can we get on with it and write our first unit test?

还没有。

Not yet.

我们的首要任务是创建“行走骨架”,我们在“首先,测试行走骨架”(第32页)中进行了描述。同样,行走骨架的目的是帮助我们充分了解需求,以便提出并验证一个粗略的系统结构。当我们了解更多信息时,我们总是可以稍后改变主意,但重要的是从描绘出我们解决方案前景的东西开始。此外,能够评估我们选择的方法并测试我们的决定也非常重要,这样我们以后就可以满怀信心地做出更改。

Our first task is to create the “walking skeleton” we described in “First, Test a Walking Skeleton” (page 32). Again, the point of the walking skeleton is to help us understand the requirements well enough to propose and validate a broad-brush system structure. We can always change our minds later, when we learn more, but it’s important to start with something that maps out the landscape of our solution. Also, it’s very important to be able to assess the approach we’ve chosen and to test our decisions so we can make changes with confidence later.

对于大多数项目来说,开发可运行的骨架需要付出惊人的努力。首先,因为决定做什么会引出有关应用程序及其在世界上的地位的各种问题。其次,因为构建、打包和部署到类似生产环境的自动化(一旦我们知道这意味着什么)会引出各种技术和组织问题。

For most projects, developing the walking skeleton takes a surprising amount of effort. First, because deciding what to do will flush out all sorts of questions about the application and its place in the world. Second, because the automation of building, packaging, and deploying into a production-like environment (once we know what that means) will flush out all sorts of technical and organizational questions.

零次迭代

Iteration Zero

图像

在大多数敏捷项目中,第一阶段是团队进行初步分析、设置物理和技术环境,并开始其他工作。由于几乎所有工作都是基础设施,因此团队不会添加太多可见的功能,因此出于调度目的,将其算作常规迭代可能没有意义。一种常见的做法是将此步骤称为迭代零:“迭代”是因为团队仍需要对其活动进行时间限制,“零”是因为它处于迭代一开始的功能开发之前。迭代零的一项重要任务是使用行走骨架来测试驱动初始架构。

In most Agile projects, there’s a first stage where the team is doing initial analysis, setting up its physical and technical environments, and otherwise getting started. The team isn’t adding much visible functionality since almost all the work is infrastructure, so it might not make sense to count this as a conventional iteration for scheduling purposes. A common practice is to call this step iteration zero: “iteration” because the team still needs to time-box its activities and “zero” because it’s before functional development starts in iteration one. One important task for iteration zero is to use the walking skeleton to test-drive the initial architecture.

当然,我们通过编写测试来开始我们的行走骨架。

Of course, we start our walking skeleton by writing a test.

我们的第一次测试

Our Very First Test

可行框架必须涵盖我们的拍卖狙击手系统的所有组件:用户界面、狙击组件以及与拍卖服务器的通信。我们能想到的测试的最小部分,也就是我们待办事项清单上的第一项,就是拍卖狙击手可以加入拍卖,然后等待拍卖结束。这个部分非常小,我们甚至不关心发送出价;我们只想知道双方可以通信,并且可以从外部测试系统(通过客户端的 GUI 和通过注入事件,就像从外部拍卖服务器一样)。一旦这些工作正常,我们就有了一个坚实的基础,可以在此基础上构建客户想要的其余功能。

The walking skeleton must cover all the components of our Auction Sniper system: the user interface, the sniping component, and the communication with an auction server. The thinnest slice we can imagine testing, the first item on our to-do list, is that the Auction Sniper can join an auction and then wait for it to close. This slice is so minimal that we’re not even concerned with sending a bid; we just want to know that the two sides can communicate and that we can test the system from outside (through the client’s GUI and by injecting events as if from the external auction server). Once that’s working, we have a solid base on which to build the rest of the features that the clients want.

我们喜欢先编写测试,就好像它的实现已经存在,然后再填写使其工作所需的一切——Abelson 和 Sussman 称之为“一厢情愿的编程” [Abelson96]。从测试开始逆向工作有助于我们专注于我们希望系统做什么,而不是陷入如何让它工作的复杂性中。所以,首先我们编写一个测试,在考虑到编程语言的表达限制的情况下,尽可能清楚地描述我们的意图。然后我们构建基础结构来支持我们想要测试系统的方式,而不是编写测试来适应现有的基础结构。这通常会占用我们前期工作的很大一部分,因为需要准备的东西太多了。有了这个基础结构,我们就可以实现功能并通过测试了。

We like to start by writing a test as if its implementation already exists, and then filling in whatever is needed to make it work—what Abelson and Sussman call “programming by wishful thinking” [Abelson96]. Working backwards from the test helps us focus on what we want the system to do, instead of getting caught up in the complexity of how we will make it work. So, first we code up a test to describe our intentions as clearly as we can, given the expressive limits of a programming language. Then we build the infrastructure to support the way we want to test the system, instead of writing the tests to fit in with an existing infrastructure. This usually takes a large part of our initial effort because there is so much to get ready. With this infrastructure in place, we can implement the feature and make the test pass.

我们想要进行的测试的概要是:

An outline of the test we want is:

1. 拍卖会上出售物品时,

1. When an auction is selling an item,

2. 拍卖狙击手已经开始竞拍该拍卖品,

2. And an Auction Sniper has started to bid in that auction,

3. 然后拍卖会收到Join拍卖狙击手的请求。

3. Then the auction will receive a Join request from the Auction Sniper.

4. 当拍卖会宣布结束的时候Close

4. When an auction announces that it is Closed,

5. 然后拍卖狙击手将显示其拍卖失败。

5. Then the Auction Sniper will show that it lost the auction.

这描述了状态机中的一个转换(见图10.1)。

This describes one transition in the state machine (see Figure 10.1).

图 10.1 狙击手加入,然后失败

Figure 10.1 A Sniper joins, then loses

图像

我们需要将其转化为可执行文件。我们使用 JUnit 作为测试框架,因为它很常见且得到广泛支持。我们还需要一些机制来控制应用程序以及应用程序正在与之对话的拍卖。

We need to translate this into something executable. We use JUnit as our test framework since it’s familiar and widely supported. We also need mechanisms to control the application and the auction that the application is talking to.

Southabee 的在线测试服务并非免费提供。我们必须提前预订并支付每个测试环节的费用,如果我们想一直运行测试,这并不实际。我们需要一个虚假的拍卖服务,我们可以从测试中控制它,使其表现得像真实的拍卖服务一样 — 或者至少像我们认为的真实拍卖服务一样,直到我们有机会对其进行实际测试。这个虚假的拍卖服务或存根服务将尽可能简单。它将连接到 XMPP 消息代理,接收来自狙击手的命令以供测试检查,并允许测试发回事件。我们不会尝试重新实现 Southabee 的在线服务,只需重新实现足以支持测试场景的部分即可。

Southabee’s On-Line test services are not freely available. We have to book ahead and pay for each test session, which is not practical if we want to run tests all the time. We’ll need a fake auction service that we can control from our tests to behave like the real thing—or at least like we think the real thing behaves until we get a chance to test against it for real. This fake auction, or stub, will be as simple as we can make it. It will connect to an XMPP message broker, receive commands from the Sniper to be checked by the test, and allow the test to send back events. We’re not trying to reimplement all of Southabee’s On-Line, just enough of it to support test scenarios.

控制狙击手应用程序更加复杂。我们希望骨架测试尽可能接近端到端地运行我们的应用程序,以显示该main()方法正确地初始化应用程序并且组件真正协同工作。这意味着我们应该从处理应用程序的公开可见功能(在本例中是其用户界面)开始,而不是直接调用其域对象。我们还希望我们的测试清楚地说明要检查的内容,并根据狙击手与其拍卖之间的关系进行编写,因此我们将在一个ApplicationRunner类中隐藏所有用于操作 Swing 的杂乱代码。我们将从编写测试开始,就好像它所需的所有代码都存在一样,然后再填写实现。

Controlling the Sniper application is more complicated. We want our skeleton test to exercise our application as close to end-to-end as possible, to show that the main() method initializes the application correctly and that the components really work together. This means that we should start by working through the publicly visible features of the application (in this case, its user interface) instead of directly invoking its domain objects. We also want our test to be clear about what is being checked, written in terms of the relationship between a Sniper and its auction, so we’ll hide all the messy code for manipulating Swing in an ApplicationRunner class. We’ll start by writing the test as if all the code it needs exists and will fill in the implementations afterwards.

public class AuctionSniperEndToEndTest {

private final FakeAuctionServer auction = new FakeAuctionServer("item-54321");

private final ApplicationRunner application = new ApplicationRunner();



@Test public void sniperJoinsAuctionUntilAuctionCloses() throws Exception {

auction.startSellingItem(); // 步骤 1

application.startBiddingIn(auction); // 步骤 2

auction.hasReceivedJoinRequestFromSniper(); // 步骤 3

auction.announceClosed(); // 步骤 4

application.showsSniperHasLostAuction(); // 步骤 5

}



// 额外清理

@After public void stopAuction() {

auction.stop();

}

@After public void stopApplication() {

application.stop();

}

}

public class AuctionSniperEndToEndTest {

private final FakeAuctionServer auction = new FakeAuctionServer("item-54321");

private final ApplicationRunner application = new ApplicationRunner();



@Test public void sniperJoinsAuctionUntilAuctionCloses() throws Exception {

auction.startSellingItem(); // Step 1

application.startBiddingIn(auction); // Step 2

auction.hasReceivedJoinRequestFromSniper(); // Step 3

auction.announceClosed(); // Step 4

application.showsSniperHasLostAuction(); // Step 5

}



// Additional cleanup

@After public void stopAuction() {

auction.stop();

}

@After public void stopApplication() {

application.stop();

}

}

我们对辅助对象的方法采用了一定的命名约定。如果方法触发事件来驱动测试,则其名称将是一个命令,例如。如果方法断言某事应该发生,则其名称将是描述性的;startBiddingIn()例如,如果应用程序未显示拍卖状态为丢失,则将抛出异常。JUnit 将在测试运行后调用这两个方法来清理运行时环境。showsSniperHasLostAuction()stop()

We’ve adopted certain naming conventions for the methods of the helper objects. If a method triggers an event to drive the test, its name will be a command, such as startBiddingIn(). If a method asserts that something should have happened, its name will be descriptive;1 for example, showsSniperHasLostAuction() will throw an exception if the application is not showing the auction status as lost. JUnit will call the two stop() methods after the test has run, to clean up the runtime environment.

1.对于语法上迂腐的人来说,触发事件的方法名称是祈使语气,而断言的名称是陈述语气。

1. For the grammatically pedantic, the names of methods that trigger events are in the imperative mood whereas the names of assertions are in the indicative mood.

在编写测试时,我们做出的一个假设是FakeAuctionServer与给定项目相关联。这与我们预期的架构相匹配,其中 Southabee's On-Line 举办多个拍卖会,每个拍卖会出售一件物品。

In writing the test, one of the assumptions we’ve made is that a FakeAuctionServer is tied to a given item. This matches the structure of our intended architecture, where Southabee’s On-Line hosts multiple auctions, each selling a single item.

一次一个域名

One Domain at a Time

图像

本次测试的语言与拍卖和狙击手有关;与用户界面中的信息传递层或组件无关 — 这些都是附带细节。保持语言的一致性有助于我们了解本次测试中的重要内容,同时在实施不可避免地发生变化时,还能起到保护我们的良好作用。

The language of this test is concerned with auctions and Snipers; there’s nothing about messaging layers or components in the user interface—that’s all incidental detail here. Keeping the language consistent helps us understand what’s significant in this test, with a nice side effect of protecting us when the implementation inevitably changes.

一些初步选择

Some Initial Choices

现在我们必须通过测试,这将需要大量的准备工作。我们需要找到或编写四个组件:XMPP 消息代理、可以通过 XMPP 进行通信的存根拍卖、GUI 测试框架以及可以应对多线程异步架构的测试工具。我们还必须通过自动构建/部署/测试流程对项目进行版本控制。与对单个类进行单元测试相比,还有很多工作要做,但这是必不可少的。即使在这个高水平上,编写测试的练习也会推动系统的开发。完成我们的第一个端到端测试将迫使我们做出一些结构性决策,例如打包和部署。

Now we have to make the test pass, which will require a lot of preparation. We need to find or write four components: an XMPP message broker, a stub auction that can communicate over XMPP, a GUI testing framework, and a test harness that can cope with our multithreaded, asynchronous architecture. We also have to get the project under version control with an automated build/deploy/test process. Compared to unit-testing a single class, there is a lot to do—but it’s essential. Even at this high level, the exercise of writing tests drives the development of the system. Working through our first end-to-end test will force some of the structural decisions we need to make, such as packaging and deployment.

首先是软件包选择,我们需要一个 XMPP 消息代理,以便应用程序与我们的存根拍卖行进行通信。经过一番调查,我们决定使用一个名为 Openfire 的开源实现及其相关的客户端库 Smack。我们还需要一个可以与 Swing 和 Smack 配合使用的高级测试框架,这两个框架都是多线程和事件驱动的。幸运的是,有几种框架可以测试 Swing 应用程序,它们处理 Swing 的多线程、事件驱动架构的方式也适用于 XMPP 消息传递。我们选择了开源的 WindowLicker,它支持我们测试中需要的异步方法。组装后,基础结构将如图10.2所示:

First the package selection, we will need an XMPP message broker to let the application talk to our stub auction house. After some investigation, we decide on an open source implementation called Openfire and its associated client library Smack. We also need a high-level test framework that can work with Swing and Smack, both of which are multithreaded and event-driven. Luckily for us, there are several frameworks for testing Swing applications and the way that they deal with Swing’s multithreaded, event-driven architecture also works well with XMPP messaging. We pick WindowLicker which is open source and supports the asynchronous approach that we need in our tests. When assembled, the infrastructure will look like Figure 10.2:

图 10.2 端到端试验台

Figure 10.2 The end-to-end test rig

图像

端到端测试

End-to-End Testing

基于事件的系统(例如我们的 Sniper)的端到端测试必须应对异步性。测试与应用程序并行运行,并且无法准确知道应用程序何时准备就绪。这与单元测试不同,在单元测试中,测试直接在同一线程中驱动对象,因此可以直接对其状态和行为做出断言。

End-to-end testing for event-based systems, such as our Sniper, has to cope with asynchrony. The tests run in parallel with the application and do not know precisely when the application is or isn’t ready. This is unlike unit testing, where a test drives an object directly in the same thread and so can make direct assertions about its state and behavior.

端到端测试无法窥视目标应用程序内部,因此必须等待检测到某些可见效果,例如用户界面更改或日志中的条目。通常的技术是轮询效果,如果在给定的时间限制内没有发生效果,则失败。进一步的复杂之处在于,目标应用程序必须在触发事件后稳定足够长的时间,以便测试能够捕获结果。等待屏幕上闪烁的值的异步测试对于自动构建来说太不可靠了,因此一种常见的技术是控制应用程序并逐步完成场景。在每个阶段,测试都等待断言通过,然后发送事件唤醒应用程序以进行下一步。有关测试异步行为的完整讨论,请参见第 14 章。

An end-to-end test can’t peek inside the target application, so it must wait to detect some visible effect, such as a user interface change or an entry in a log. The usual technique is to poll for the effect and fail if it doesn’t happen within a given time limit. There’s a further complexity in that the target application has to stabilize after the triggering event long enough for the test to catch the result. An asynchronous test waiting for a value that just flashes on the screen will be too unreliable for an automated build, so a common technique is to control the application and step through the scenario. At each stage, the test waits for an assertion to pass, then sends an event to wake the application for the next step. See Chapter 14 for a full discussion of testing asynchronous behavior.

所有这些都使得端到端测试变得更慢、更脆弱(也许只是今天的测试网络很忙),因此失败可能需要解释。我们听说过有些团队在报告时序相关测试失败几次之后才会报告。这与每次都必须通过的单元测试不同。

All this makes end-to-end testing slower and more brittle (perhaps the test network is just busy today), so failures might need interpretation. We’ve heard of teams where timing-related tests have to fail several times in a row before they’re reported. This is unlike unit tests which must all pass every time.

在我们的案例中,Swing 和消息传递基础结构都是异步的,因此使用 WindowLicker(轮询值)来驱动 Sniper 可以覆盖我们端到端测试的自然异步性。

In our case, both Swing and the messaging infrastructure are asynchronous, so using WindowLicker (which polls for values) to drive the Sniper covers the natural asynchrony of our end-to-end testing.

准备开始

Ready to Start

您可能已经注意到我们跳过了一点:第一个测试并不是真正的端到端测试。它不包括真正的拍卖服务,因为这并不容易获得。测试驱动开发技能的一个重要部分是判断在哪里设置测试的边界以及如何最终覆盖所有内容。在这种情况下,我们必须从基于 Southabee 的 On-Line 文档的虚假拍卖服务开始。文档可能正确也可能不正确,因此我们将在项目计划中将其记录为已知风险,并在我们拥有足够的功能来完成有意义的交易后立即安排时间针对真实服务器进行测试 - 即使我们最终在真正的拍卖中购买一对丑陋(但便宜)的蜡烛图。我们越早发现差异,基于该误解的代码就越少,修复它的时间就越多。

You might have noticed that we skipped over one point: this first test is not really end-to-end. It doesn’t include the real auction service because that is not easily available. An important part of the test-driven development skills is judging where to set the boundaries of what to test and how to eventually cover everything. In this case, we have to start with a fake auction service based on the documentation from Southabee’s On-Line. The documentation might or might not be correct, so we will record that as a known risk in the project plan and schedule time to test against the real server as soon as we have enough functionality to complete a meaningful transaction—even if we end up buying a hideous (but cheap) pair of candlesticks in a real auction. The sooner we find a discrepancy, the less code we will have based on that misunderstanding and the more time to fix it.

我们最好继续做下去。

We’d better get on with it.

第11章 通过第一项考验

Chapter 11. Passing the First Test

我们编写测试基础架构来驱动我们不存在的应用程序,这样我们就可以让第一个测试失败。我们反复让测试失败并修复症状,直到我们有一个通过第一个测试的最小工作应用程序。我们非常缓慢地逐步完成这个过程以展示这个过程是如何工作的。

In which we write test infrastructure to drive our non-existent application, so that we can make the first test fail. We repeatedly fail the test and fix symptoms, until we have a minimal working application that passes the first test. We step through this very slowly to show how the process works.

搭建测试台

Building the Test Rig

每次测试开始时,我们的测试脚本都会启动 Openfire 服务器,为狙击手和拍卖创建帐户,然后运行测试。每次测试都会启动应用程序和虚假拍卖的实例,然后通过服务器测试它们的通信。首先,我们将在同一台主机上运行所有内容。之后,随着基础设施的稳定,我们可以考虑在不同的机器上运行不同的组件,这将与实际部署更加匹配。

At the start of every test run, our test script starts up the Openfire server, creates accounts for the Sniper and the auction, and then runs the tests. Each test will start instances of the application and the fake auction, and then test their communication through the server. At first, we’ll run everything on the same host. Later, as the infrastructure stabilizes, we can consider running different components on different machines, which will be a better match to the real deployment.

这就给我们留下了两个为测试基础设施编写的组件:ApplicationRunnerFakeAuctionServer

This leaves us with two components to write for the test infrastructure: ApplicationRunner and FakeAuctionServer.

应用程序运行器

The Application Runner

是一个ApplicationRunner封装了所有管理功能并与我们正在构建的 Swing 应用程序进行通信的对象。它像从命令行一样运行应用程序,获取并保存对其主窗口的引用,以查询 GUI 的状态并在测试结束时关闭应用程序。

An ApplicationRunner is an object that wraps up all management and communicating with the Swing application we’re building. It runs the application as if from the command line, obtaining and holding a reference to its main window for querying the state of the GUI and for shutting down the application at the end of the test.

我们不需要在这里做太多,因为我们可以依靠 WindowLicker 来完成艰苦的工作:查找和控制 Swing GUI 组件,与 Swing 的线程和事件队列同步,并将所有这些包装在一个简单的 API 后面。1 WindowLicker 具有ComponentDriver的概念:可以操作 Swing 用户界面中的功能的对象。如果 ComponentDriver 找不到它引用的 Swing 组件,它将超时并出现错误。对于此测试,我们正在寻找显示给定字符串的标签组件;如果我们的应用程序不生成此标签,我们将收到异常。以下是实现(为清楚起见省略了常量)和一些解释:

We don’t have to do much here, because we can rely on WindowLicker to do the hard work: find and control Swing GUI components, synchronize with Swing’s threads and event queue, and wrap that all up behind a simple API.1 WindowLicker has the concept of a ComponentDriver: an object that can manipulate a feature in a Swing user interface. If a ComponentDriver can’t find the Swing component it refers to, it will time out with an error. For this test, we’re looking for a label component that shows a given string; if our application doesn’t produce this label, we’ll get an exception. Here’s the implementation (with the constants left out for clarity) and some explanation:

1.我们假设您知道 Swing 的工作原理;还有很多其他书籍对此进行了很好的描述。这里的要点是,它是一个事件驱动的框架,它创建自己的内部线程来分派事件,因此我们无法准确知道事情何时会发生。

1. We’re assuming that you know how Swing works; there are many other books that do a good job of describing it. The essential point here is that it’s an event-driven framework that creates its own internal threads to dispatch events, so we can’t be precise about when things will happen.

公共类 ApplicationRunner {

公共静态最终字符串 SNIPER_ID =“狙击手”;

公共静态最终字符串 SNIPER_PASSWORD =“狙击手”;

私人 AuctionSniperDriver 驱动程序;



公共 void startBiddingIn(最终 FakeAuctionServer 拍卖){

线程线程 = 新线程(“测试应用程序”){

@Override 公共 void run(){图像

尝试 {

Main.main(XMPP_HOSTNAME,SNIPER_ID,SNIPER_PASSWORD,拍卖.getItemId()); 图像

} catch(异常 e){

e.printStackTrace(); 图像

}

}

};

thread.setDaemon(true);

thread.start();

驱动程序 = 新 AuctionSniperDriver(1000);图像

驱动程序.showsSniperStatus(Main.STATUS_JOINING); 图像

}

公共 void showsSniperHasLostAuction(){

驱动程序.showsSniperStatus(Main.STATUS_LOST); 图像

}

公共 void stop() {

如果 (driver != null) {

driver.dispose(); 图像

}

}

}

public class ApplicationRunner {

public static final String SNIPER_ID = "sniper";

public static final String SNIPER_PASSWORD = "sniper";

private AuctionSniperDriver driver;



public void startBiddingIn(final FakeAuctionServer auction) {

Thread thread = new Thread("Test Application") {

@Override public void run() {

try {

Main.main(XMPP_HOSTNAME, SNIPER_ID, SNIPER_PASSWORD, auction.getItemId());

} catch (Exception e) {

e.printStackTrace();

}

}

};

thread.setDaemon(true);

thread.start();

driver = new AuctionSniperDriver(1000);

driver.showsSniperStatus(Main.STATUS_JOINING);

}

public void showsSniperHasLostAuction() {

driver.showsSniperStatus(Main.STATUS_LOST);

}

public void stop() {

if (driver != null) {

driver.dispose();

}

}

}

图像我们通过应用程序的main()函数来调用它,以确保我们正确地组装了各个部分。我们遵循惯例,即应用程序的入口点是Main顶级包中的一个类。如果 Swing 组件位于同一个 JVM 中,WindowLicker 可以控制它们,因此我们会在新线程中启动 Sniper。理想情况下,测试会在新进程中启动 Sniper,但这会更难测试;我们认为这是一个合理的折衷方案。

We call the application through its main() function to make sure we’ve assembled the pieces correctly. We’re following the convention that the entry point to the application is a Main class in the top-level package. WindowLicker can control Swing components if they’re in the same JVM, so we start the Sniper in a new thread. Ideally, the test would start the Sniper in a new process, but that would be much harder to test; we think this is a reasonable compromise.

图像为了在此阶段保持简单,我们假设我们只竞标一件物品,并将标识符传递给main()

To keep things simple at this stage, we’ll assume that we’re only bidding for one item and pass the identifier to main().

图像如果main()抛出异常,我们只需将其打印出来。无论我们运行什么测试都会失败,我们可以在输出中查找堆栈跟踪。稍后,我们将正确处理异常。

If main() throws an exception, we just print it out. Whatever test we’re running will fail and we can look for the stack trace in the output. Later, we’ll handle exceptions properly.

图像我们缩短了查找框架和组件的超时时间。默认值比我们像这样的简单应用程序所需的时间要长,并且会在测试失败时减慢测试速度。我们使用一秒钟,这足以消除轻微的运行时延迟。

We turn down the timeout period for finding frames and components. The default values are longer than we need for a simple application like this one and will slow down the tests when they fail. We use one second, which is enough to smooth over minor runtime delays.

图像我们等待状态变为,Joining这样我们就知道应用程序已尝试连接。此断言表示用户界面某处有一个标签,描述狙击手的状态。

We wait for the status to change to Joining so we know that the application has attempted to connect. This assertion says that somewhere in the user interface there’s a label that describes the Sniper’s state.

图像当狙击手输掉拍卖时,我们希望它显示Lost状态。如果没有显示,驱动程序将抛出异​​常。

When the Sniper loses the auction, we expect it to show a Lost status. If this doesn’t happen, the driver will throw an exception.

图像测试结束后,我们告诉驾驶员处理该窗口,以确保它在被垃圾收集之前不会在另一个测试中被拾取。

After the test, we tell the driver to dispose of the window to make sure it won’t be picked up in another test before being garbage-collected.

这只是我们测试专用的AuctionSniperDriverWindowLicker 的扩展:JFrameDriver

The AuctionSniperDriver is simply an extension of a WindowLicker JFrameDriver specialized for our tests:

公共类 AuctionSniperDriver 扩展了 JFrameDriver {

公共 AuctionSniperDriver(int timeoutMillis) {

超级(新 GesturePerformer(),

JFrameDriver.topLevelFrame(

named(MainWindow.MAIN_WINDOW_NAME),

showingOnScreen()),

新 AWTEventQueueProber(timeoutMillis, 100));

}



公共 void showsSniperStatus(String statusText) {

新 JLabelDriver(

this,named(Main.SNIPER_STATUS_NAME)).hasText(equalTo(statusText));

}

}

public class AuctionSniperDriver extends JFrameDriver {

public AuctionSniperDriver(int timeoutMillis) {

super(new GesturePerformer(),

JFrameDriver.topLevelFrame(

named(MainWindow.MAIN_WINDOW_NAME),

showingOnScreen()),

new AWTEventQueueProber(timeoutMillis, 100));

}



public void showsSniperStatus(String statusText) {

new JLabelDriver(

this, named(Main.SNIPER_STATUS_NAME)).hasText(equalTo(statusText));

}

}

在构造时,它会尝试在给定的超时时间内为拍卖狙击手找到可见的顶层窗口。该方法showsSniperStatus()会在用户界面中查找相关标签,并确认其显示给定的状态。如果驱动程序找不到预期的功能,则会抛出异常并导致测试失败。

On construction, it attempts to find a visible top-level window for the Auction Sniper within the given timeout. The method showsSniperStatus() looks for the relevant label in the user interface and confirms that it shows the given status. If the driver cannot find a feature it expects, it will throw an exception and fail the test.

假拍卖

The Fake Auction

AFakeAuctionServer是替代服务器,允许测试检查拍卖狙击手如何使用 XMPP 消息与拍卖进行交互。它有三项职责:它必须连接到 XMPP 代理并接受来自狙击手的加入聊天的请求;它必须接收来自狙击手的聊天消息,如果在某个超时时间内没有消息到达,则失败;并且,它必须允许测试按照 Southabee 的 On-Line 指定的方式将消息发回狙击手。

A FakeAuctionServer is a substitute server that allows the test to check how the Auction Sniper interacts with an auction using XMPP messages. It has three responsibilities: it must connect to the XMPP broker and accept a request to join the chat from the Sniper; it must receive chat messages from the Sniper or fail if no message arrives within some timeout; and, it must allow the test to send messages back to the Sniper as specified by Southabee’s On-Line.

Smack(XMPP 客户端库)是事件驱动的,因此虚假拍卖必须注册侦听器对象才能回调。事件分为两个级别:聊天相关事件(例如有人加入)和聊天内部事件(例如收到消息)。我们需要侦听这两个事件。

Smack (the XMPP client library) is event-driven, so the fake auction has to register listener objects for it to call back. There are two levels of events: events about a chat, such as people joining, and events within a chat, such as messages being received. We need to listen for both.

我们将从实现该startSellingItem()方法开始。首先,它连接到 XMPP 代理,使用项目标识符构造登录名;然后注册一个ChatManagerListener。当狙击手连接时,Smack 将使用表示会话的对象调用此侦听器Chat。假拍卖会保留聊天记录,以便与狙击手交换消息。

We’ll start by implementing the startSellingItem() method. First, it connects to the XMPP broker, using the item identifier to construct the login name; then it registers a ChatManagerListener. Smack will call this listener with a Chat object that represents the session when a Sniper connects in. The fake auction holds on to the chat so it can exchange messages with the Sniper.

图 11.1 Smack 对象和回调

Figure 11.1 Smack objects and callbacks

图像

到目前为止,我们已经:

So far, we have:

公共类 FakeAuctionServer {

公共静态最终字符串 ITEM_ID_AS_LOGIN = “拍卖-%s”;

公共静态最终字符串 AUCTION_RESOURCE = “拍卖”;

公共静态最终字符串 XMPP_HOSTNAME = “localhost”;

私有静态最终字符串 AUCTION_PASSWORD = “拍卖”;



私有最终字符串 itemId;

私有最终 XMPPConnection 连接;

私人聊天 currentChat;



公共 FakeAuctionServer(String itemId){

this.itemId = itemId;

this.connection = new XMPPConnection(XMPP_HOSTNAME);

}



公共 void startSellingItem()抛出 XMPPException {

connection.connect();

connection.login(String.format(ITEM_ID_AS_LOGIN,itemId),

AUCTION_PASSWORD,AUCTION_RESOURCE);

连接.getChatManager().addChatListener(

新ChatManagerListener(){

public void chatCreated(Chat chat,boolean createdLocally){

currentChat = chat;

}

});

}



public String getItemId(){

return itemId;

}

}

public class FakeAuctionServer {

public static final String ITEM_ID_AS_LOGIN = "auction-%s";

public static final String AUCTION_RESOURCE = "Auction";

public static final String XMPP_HOSTNAME = "localhost";

private static final String AUCTION_PASSWORD = "auction";



private final String itemId;

private final XMPPConnection connection;

private Chat currentChat;



public FakeAuctionServer(String itemId) {

this.itemId = itemId;

this.connection = new XMPPConnection(XMPP_HOSTNAME);

}



public void startSellingItem() throws XMPPException {

connection.connect();

connection.login(String.format(ITEM_ID_AS_LOGIN, itemId),

AUCTION_PASSWORD, AUCTION_RESOURCE);

connection.getChatManager().addChatListener(

new ChatManagerListener() {

public void chatCreated(Chat chat, boolean createdLocally) {

currentChat = chat;

}

});

}



public String getItemId() {

return itemId;

}

}

最小的 Fake 实现

A Minimal Fake Implementation

图像

我们想再次强调,这个假货只是一个最小实现,只是为了支持测试。例如,我们使用一个实例变量来保存聊天对象。真正的拍卖服务器会为所有竞标者管理多个聊天 - 但这是一个假货;它的唯一目的是支持测试,因此只需要一个聊天。

We want to emphasize again that this fake is a minimal implementation just to support testing. For example, we use a single instance variable to hold the chat object. A real auction server would manage multiple chats for all the bidders—but this is a fake; its only purpose is to support the test, so it only needs one chat.

接下来,我们必须将 添加MessageListenerchat以接受来自狙击手的消息。这意味着我们需要在运行测试的线程和向侦听器提供消息的 Smack 线程之间进行协调——测试必须等待消息到达,如果没有到达,则超时——因此我们将使用包BlockingQueue中的单个元素java.util.concurrent。正如我们chat在测试中只有一个一样,我们希望一次只处理一条消息。为了使我们的意图更清晰,我们将队列包装在一个辅助类中SingleMessageListener。以下是 的其余部分FakeAuctionServer

Next, we have to add a MessageListener to the chat to accept messages from the Sniper. This means that we need to coordinate between the thread that runs the test and the Smack thread that feeds messages to the listener—the test has to wait for messages to arrive and time out if they don’t—so we’ll use a single-element BlockingQueue from the java.util.concurrent package. Just as we only have one chat in the test, we expect to process only one message at a time. To make our intentions clearer, we wrap the queue in a helper class SingleMessageListener. Here’s the rest of FakeAuctionServer:

公共类 FakeAuctionServer {

私有最终 SingleMessageListener messageListener = new SingleMessageListener();



公共 void startSellingItem() 抛出 XMPPException {

连接.connect();

连接.login(格式(ITEM_ID_AS_LOGIN, itemId),

AUCTION_PASSWORD, AUCTION_RESOURCE);

连接.getChatManager().addChatListener(

新 ChatManagerListener() {

公共 void chatCreated(聊天聊天,布尔 createdLocally) {

currentChat = 聊天;

chat.addMessageListener(messageListener);

}

});

}



公共 void hasReceivedJoinRequestFromSniper() 抛出 InterruptedException {

messageListener.receivesAMessage(); 图像

}



公共 void advertiseClosed() 抛出 XMPPException {

currentChat.sendMessage(新 Message()); 图像

}



公共 void stop() {

连接.disconnect(); 图像

}

}



公共类 SingleMessageListener 实现 MessageListener {

private final ArrayBlockingQueue<Message> messages =

new ArrayBlockingQueue<Message>(1);



public void processMessage(Chat chat, Message message) {

messages.add(message);

}



public void receivedAMessage() 抛出 InterruptedException {

assertThat("Message", messages.poll(5, TimeUnit.SECONDS), is(notNullValue())); 图像

}

}

public class FakeAuctionServer {

private final SingleMessageListener messageListener = new SingleMessageListener();



public void startSellingItem() throws XMPPException {

connection.connect();

connection.login(format(ITEM_ID_AS_LOGIN, itemId),

AUCTION_PASSWORD, AUCTION_RESOURCE);

connection.getChatManager().addChatListener(

new ChatManagerListener() {

public void chatCreated(Chat chat, boolean createdLocally) {

currentChat = chat;

chat.addMessageListener(messageListener);

}

});

}



public void hasReceivedJoinRequestFromSniper() throws InterruptedException {

messageListener.receivesAMessage();

}



public void announceClosed() throws XMPPException {

currentChat.sendMessage(new Message());

}



public void stop() {

connection.disconnect();

}

}



public class SingleMessageListener implements MessageListener {

private final ArrayBlockingQueue<Message> messages =

new ArrayBlockingQueue<Message>(1);



public void processMessage(Chat chat, Message message) {

messages.add(message);

}



public void receivesAMessage() throws InterruptedException {

assertThat("Message", messages.poll(5, TimeUnit.SECONDS), is(notNullValue()));

}

}

图像测试需要知道Join消息何时到达。我们只需检查是否有消息到达,因为 Sniper 一开始只会发送Join消息;随着应用程序的发展,我们会填写更多详细信息。如果 5 秒内未收到任何消息,此实现将失败。

The test needs to know when a Join message has arrived. We just check whether any message has arrived, since the Sniper will only be sending Join messages to start with; we’ll fill in more detail as we grow the application. This implementation will fail if no message is received within 5 seconds.

图像测试需要能够模拟拍卖宣布何时结束,这就是我们保留currentChat何时开始的原因。与Join请求一样,虚假拍卖只会发送一条空消息,因为这是我们目前支持的唯一事件。

The test needs to be able to simulate the auction announcing when it closes, which is why we held onto the currentChat when it opened. As with the Join request, the fake auction just sends an empty message, since this is the only event we support so far.

图像 stop()关闭连接。

stop() closes the connection.

图像该子句is(notNullValue())使用 Hamcrest匹配器语法。我们Matcher在“方法”中描述了它;现在,只需知道它检查监听器是否在超时期限内收到了消息即可

The clause is(notNullValue()) uses the Hamcrest matcher syntax. We describe Matchers in “Methods” (page 339); for now, it’s enough to know that this checks that the Listener has received a message within the timeout period.

消息代理

The Message Broker

还有一个组件需要提及,它不需要任何编码 — 安装 XMPP 消息代理。我们在本地主机上设置了一个 Openfire 实例。在我们的端到端测试中,Sniper 和假拍卖即使在同一进程中运行,也会通过此服务器进行通信。我们还设置了登录名以匹配我们将在测试中使用的少量项目标识符。

There’s one more component to mention which doesn’t involve any coding—the installation of an XMPP message broker. We set up an instance of Openfire on our local host. The Sniper and fake auction in our end-to-end tests, even though they’re running in the same process, will communicate through this server. We also set up logins to match the small number of item identifiers that we’ll be using in our tests.

工作上的妥协

A Working Compromise

图像

正如我们之前所写,我们在这个阶段会稍微作弊以保持开发进度。我们希望所有开发人员都有自己的环境,这样他们在运行测试时就不会互相干扰。例如,我们看到团队让他们的生活变得非常复杂,因为他们不想为每个开发人员创建一个数据库实例。在专业组织中,我们还希望看到至少一个代表生产环境的测试平台,包括跨网络的处理分布以及使用它来确保系统正常运行的构建周期。

As we wrote before, we are cheating a little at this stage to keep development moving. We want all the developers to have their own environments so they don’t interfere with each other when running their tests. For example, we’ve seen teams make their lives very complicated because they didn’t want to create a database instance for each developer. In a professional organization, we would also expect to see at least one test rig that represents the production environment, including the distribution of processing across a network and a build cycle that uses it to make sure the system works.

考试失败和通过

Failing and Passing the Test

我们有足够的基础架构来运行测试并观察它是否失败。在本章的剩余部分,我们将一点一点地添加功能,直到最终使测试通过。当我们第一次开始使用这种技术时,感觉太过繁琐:“只需编写代码,我们知道该怎么做!”随着时间的推移,我们意识到它不会花费更长时间,而且我们的进度更加可预测。一次只关注一个方面有助于我们确保我们理解它;通常,当我们让某样东西正常工作时,它就会一直正常工作。在无需讨论解决方案的地方,许多这些步骤几乎不需要花费任何时间——解释它们所花的时间比实施它们所花的时间更长。

We have enough infrastructure in place to run the test and watch it fail. For the rest of this chapter we’ll add functionality, a tiny slice at a time, until eventually we make the test pass. When we first started using this technique, it felt too fussy: “Just write the code, we know what to do!” Over time, we realized that it didn’t take any longer and that our progress was much more predictable. Focusing on just one aspect at a time helps us to make sure we understand it; as a rule, when we get something working, it stays working. Where there’s no need to discuss the solution, many of these steps take hardly any time at all—they take longer to explain than to implement.

我们首先为ant编写一个构建脚本。我们将跳过其内容的细节,因为这现在是标准做法,但重点是我们始终有一个命令可以可靠地编译、构建、部署和测试应用程序,并且我们会重复运行它。只有在自动构建和测试工作正常后,我们才会开始编码。

We start by writing a build script for ant. We’ll skip over the details of its content, since it’s standard practice these days, but the important point is that we always have a single command that reliably compiles, builds, deploys, and tests the application, and that we run it repeatedly. We only start coding once we have an automated build and test working.

在此阶段,我们将描述每个步骤,依次讨论每个测试失败。稍后我们将加快速度。

At this stage, we’ll describe each step, discussing each test failure in turn. Later we’ll speed up the pace.

第一个用户界面

First User Interface

测试失败

测试找不到名为 的用户界面组件"Auction Sniper Main"

The test can’t find a user interface component with the name "Auction Sniper Main".

java.lang.AssertionError:试图 在所有顶层窗口中

查找...

恰好 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)但是... 所有顶层窗口均包含 0 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)[...]在 auctionsniper.ApplicationRunner.stop()在 auctionsniper.AuctionSniperEndToEndTest.stopApplication() [...]















java.lang.AssertionError:

Tried to look for...

exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

but...

all top level windows

contained 0 JFrame (with name "Auction Sniper Main" and showing on screen)

[...]

at auctionsniper.ApplicationRunner.stop()

at auctionsniper.AuctionSniperEndToEndTest.stopApplication()

[...]

WindowLicker 的错误报告非常详细,试图让失败变得容易理解。在本例中,我们甚至找不到顶层框架,因此 JUnit 在开始测试之前就失败了。堆栈跟踪来自@After停止应用程序的方法。

WindowLicker is verbose in its error reporting, trying to make failures easy to understand. In this case, we couldn’t even find the top-level frame so JUnit failed before even starting the test. The stack trace comes from the @After method that stops the application.

执行

我们的应用程序需要一个顶层窗口。我们在扩展 Swing 的 包MainWindow中编写一个类,并从 调用它。它所做的就是创建一个具有正确名称的窗口。auctionsniper.uiJFramemain()

We need a top-level window for our application. We write a MainWindow class in the auctionsniper.ui package that extends Swing’s JFrame, and call it from main(). All it will do is create a window with the right name.

公共类 Main {

私有 MainWindow ui;



公共 Main() 抛出异常 {

startUserInterface()

}



公共静态 void main(String... args) 抛出异常 {

Main main = new Main();

}



私有 void startUserInterface() 抛出异常 {

SwingUtilities.invokeAndWait(new Runnable() {

公共 void run() {

ui = new MainWindow();

}

});

}

}

公共类 MainWindow 扩展 JFrame {

公共 MainWindow() {

super("拍卖狙击手");

setName(MAIN_WINDOW_NAME);

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setVisible(true);

}

}

public class Main {

private MainWindow ui;



public Main() throws Exception {

startUserInterface()

}



public static void main(String... args) throws Exception {

Main main = new Main();

}



private void startUserInterface() throws Exception {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() {

ui = new MainWindow();

}

});

}

}

public class MainWindow extends JFrame {

public MainWindow() {

super("Auction Sniper");

setName(MAIN_WINDOW_NAME);

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setVisible(true);

}

}

不幸的是,这有点混乱,因为 Swing 要求我们在其事件分派线程上创建用户界面。我们进一步复杂化了实现,以便我们可以在代码中保留主窗口对象。这里并不是绝对必要的,但我们认为我们应该把它做完。

Unfortunately, this is a little messy because Swing requires us to create the user interface on its event dispatch thread. We’ve further complicated the implementation so we can hang on to the main window object in our code. It’s not strictly necessary here but we thought we’d get it over with.

笔记

图 11.2中的用户界面确实非常小。它看起来不怎么样,但它确认我们可以启动一个应用程序窗口并连接到它。

The user interface in Figure 11.2 really is minimal. It does not look like much but it confirms that we can start up an application window and connect to it.

图 11.2 只是一个顶层窗口

Figure 11.2 Just a top-level window

图像

我们的测试仍然失败,但我们已经向前迈进了一步。现在我们知道我们的线束正在工作,这让我们在继续开发更有趣的功能时不用担心。

Our test still fails, but we’ve moved on a step. Now we know that our harness is working, which is one less thing to worry about as we move on to more interesting functionality.

显示狙击状态

Showing the Sniper State

测试失败

测试找到了一个顶层窗口,但没有显示狙击手的当前状态。首先,狙击手应该Joining在等待拍卖响应时显示。

The test finds a top-level window, but no display of the current state of the Sniper. To start with, the Sniper should show Joining while waiting for the auction to respond.

java.lang.AssertionError:试图 在所有顶层窗口中

查找...恰好 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)中的

1 个 JLabel(名称为“sniper status”),并检查其标签文本是否为“Joining”,但... 所有顶层窗口均 包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上), 在 com.objogate.wl.AWTEventQueueProber.check() [...]处包含 0 个 JLabel(名称为“sniper status”), 在 AuctionSniperDriver.showsSniperStatus() 处 包含 ApplicationRunner.startBiddingIn() 处包含 AuctionSniperEndToEndTest.sniperJoinsAuctionUntilAuctionCloses() [...]

























java.lang.AssertionError:

Tried to look for...

exactly 1 JLabel (with name "sniper status")

in exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

and check that its label text is "Joining"

but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 0 JLabel (with name "sniper status")

at com.objogate.wl.AWTEventQueueProber.check()

[...]

at AuctionSniperDriver.showsSniperStatus()

at ApplicationRunner.startBiddingIn()

at AuctionSniperEndToEndTest.sniperJoinsAuctionUntilAuctionCloses()

[...]

执行

我们添加一个代表狙击手状态的标签MainWindow

We add a label representing the Sniper’s state to MainWindow.

公共类 MainWindow 扩展了 JFrame {

公共静态最终字符串 SNIPER_STATUS_NAME = "狙击手状态";

私有最终 JLabel sniperStatus = createLabel(STATUS_JOINING);



公共 MainWindow() {

超级("拍卖狙击手");

setName(MAIN_WINDOW_NAME);

添加(sniperStatus);

pack();

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setVisible(true);

}



私有静态 JLabel createLabel(String initialText) {

JLabel result = new JLabel(initialText);

result.setName(SNIPER_STATUS_NAME);

result.setBorder(new LineBorder(Color.BLACK));

返回结果;

}

}

public class MainWindow extends JFrame {

public static final String SNIPER_STATUS_NAME = "sniper status";

private final JLabel sniperStatus = createLabel(STATUS_JOINING);



public MainWindow() {

super("Auction Sniper");

setName(MAIN_WINDOW_NAME);

add(sniperStatus);

pack();

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setVisible(true);

}



private static JLabel createLabel(String initialText) {

JLabel result = new JLabel(initialText);

result.setName(SNIPER_STATUS_NAME);

result.setBorder(new LineBorder(Color.BLACK));

return result;

}

}

笔记

这是另一个微小的变化,但现在我们可以在应用程序中显示一些内容,如图11.3所示。

Another minimal change, but now we can show some content in our application, as in Figure 11.3.

图 11.3 显示 Joining 状态

Figure 11.3 Showing Joining status

图像

连接至拍卖

Connecting to the Auction

测试失败

我们的用户界面正在运行,但拍卖没有收到Join狙击手的请求。

Our user interface is working, but the auction does not receive a Join request from the Sniper.

java.lang.AssertionError:

预期:不为空

在 FakeAuctionServer 的 SingleMessageListener.receivesAMessage() 处的

org.junit.Assert.assertThat() 处为空。 在 AuctionSniperEndToEndTest.sniperJoinsAuctionUntilAuctionCloses( ) 处的 hasReceivedJoinRequestFromSniper () [...]







java.lang.AssertionError:

Expected: is not null

got: null

at org.junit.Assert.assertThat()

at SingleMessageListener.receivesAMessage()

at FakeAuctionServer.hasReceivedJoinRequestFromSniper()

at AuctionSniperEndToEndTest.sniperJoinsAuctionUntilAuctionCloses()

[...]

这个失败消息有点神秘,但是堆栈跟踪中的名称告诉我们出了什么问题。

This failure message is a bit cryptic, but the names in the stack trace tell us what’s wrong.

执行

我们编写了一个简单的实现来帮助我们克服这个失败。它连接到聊天室Main并发送一条空消息。我们创建一个 nullMessageListener来允许我们创建一个Chat用于发送空的初始消息,因为我们还不关心接收消息。

We write a simplistic implementation to get us past this failure. It connects to the chat in Main and sends an empty message. We create a null MessageListener to allow us to create a Chat for sending the empty initial message, since we don’t yet care about receiving messages.

公共类 Main {

私有静态最终 int ARG_HOSTNAME = 0;

私有静态最终 int ARG_USERNAME = 1;

私有静态最终 int ARG_PASSWORD = 2;

私有静态最终 int ARG_ITEM_ID = 3;



公共静态最终字符串 AUCTION_RESOURCE = "拍卖";

公共静态最终字符串 ITEM_ID_AS_LOGIN = "拍卖-%s";

公共静态最终字符串 AUCTION_ID_FORMAT =

ITEM_ID_AS_LOGIN + "@%s/" + AUCTION_RESOURCE;



[...]



公共静态 void main(String ... args)抛出异常 {

Main main = new Main();

XMPPConnection connection = connectTo(args [ARG_HOSTNAME],

args [ARG_USERNAME],

args [ARG_PASSWORD]);

聊天 chat = connection.getChatManager().createChat(

auctionId(args[ARG_ITEM_ID], connection),

new MessageListener() {

public void processMessage(Chat aChat, Message message) {

// 尚无任何内容

}

});

chat.sendMessage(new Message());

}



private static XMPPConnection

connectTo(String hostname, String username, String password)

throws XMPPException

{

XMPPConnection connection = new XMPPConnection(hostname);

connection.connect();

connection.login(username, password, AUCTION_RESOURCE);



return connection;

}



private static String auctionId(String itemId, XMPPConnection connection) {

return String.format(AUCTION_ID_FORMAT, itemId,

connection.getServiceName());

}

[...]

}

public class Main {

private static final int ARG_HOSTNAME = 0;

private static final int ARG_USERNAME = 1;

private static final int ARG_PASSWORD = 2;

private static final int ARG_ITEM_ID = 3;



public static final String AUCTION_RESOURCE = "Auction";

public static final String ITEM_ID_AS_LOGIN = "auction-%s";

public static final String AUCTION_ID_FORMAT =

ITEM_ID_AS_LOGIN + "@%s/" + AUCTION_RESOURCE;



[...]



public static void main(String... args) throws Exception {

Main main = new Main();

XMPPConnection connection = connectTo(args[ARG_HOSTNAME],

args[ARG_USERNAME],

args[ARG_PASSWORD]);

Chat chat = connection.getChatManager().createChat(

auctionId(args[ARG_ITEM_ID], connection),

new MessageListener() {

public void processMessage(Chat aChat, Message message) {

// nothing yet

}

});

chat.sendMessage(new Message());

}



private static XMPPConnection

connectTo(String hostname, String username, String password)

throws XMPPException

{

XMPPConnection connection = new XMPPConnection(hostname);

connection.connect();

connection.login(username, password, AUCTION_RESOURCE);



return connection;

}



private static String auctionId(String itemId, XMPPConnection connection) {

return String.format(AUCTION_ID_FORMAT, itemId,

connection.getServiceName());

}

[...]

}

笔记

这表明我们可以建立从狙击手到拍卖的连接,这意味着我们必须整理细节,例如从命令行参数解释项目和用户凭据以及使用 Smack 库。我们将消息内容留到以后再处理,因为我们只有一种消息类型,因此发送一个空值就足以证明连接。

This shows that we can establish a connection from the Sniper to the auction, which means we had to sort out details such as interpreting the item and user credentials from the command-line arguments and using the Smack library. We’re leaving the message contents until later because we only have one message type, so sending an empty value is enough to prove the connection.

这种实现方式可能看起来过于幼稚——毕竟,我们应该能够为如此简单的事情设计一个结构,但我们经常发现,编写少量丑陋的代码并观察其结果会很值得。它有助于我们在走得太远之前测试我们的想法,有时结果可能会令人惊讶。重要的是确保我们不会留下丑陋的东西。

This implementation may seem gratuitously naive—after all, we should be able to design a structure for something as simple as this, but we’ve often found it worth writing a small amount of ugly code and seeing how it falls out. It helps us to test our ideas before we’ve gone too far, and sometimes the results can be surprising. The important point is to make sure we don’t leave it ugly.

我们特意将连接代码放在invokeAndWait()创建 的 Swing 调用之外MainWindow,因为我们希望在尝试任何更复杂的操作之前用户界面能够稳定下来。

We make a point of keeping the connection code out of the Swing invokeAndWait() call that creates the MainWindow, because we want the user interface to settle before we try anything more complicated.

收到拍卖的回复

Receiving a Response from the Auction

测试失败

建立连接后,狙击手应该会收到并显示Lost拍卖的响应。但它目前还不能:

With a connection established, the Sniper should receive and display the Lost response from the auction. It doesn’t yet:

java.lang.AssertionError:试图 在所有顶层窗口中

查找...

恰好 1 个

JFrame(名称为“Auction Sniper Main”并显示在屏幕上)中的1 个 JLabel(名称为“sniper status”) ,并检查其标签文本是否为“Lost”但... 所有顶层窗口均 包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上) 包含 1 个 JLabel(名称为“sniper status”)标签文本为“Joining” [...] 在 AuctionSniperDriver.showsSniperStatus() 在 ApplicationRunner.showsSniperHasLostAuction() 在 AuctionSniperEndToEndTest.sniperJoinsAuctionUntilAuctionCloses() [...]























java.lang.AssertionError:

Tried to look for...

exactly 1 JLabel (with name "sniper status")

in exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

and check that its label text is "Lost"

but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JLabel (with name "sniper status")

label text was "Joining"

[...]

at AuctionSniperDriver.showsSniperStatus()

at ApplicationRunner.showsSniperHasLostAuction()

at AuctionSniperEndToEndTest.sniperJoinsAuctionUntilAuctionCloses()

[...]

执行

我们需要将用户界面附加到聊天室,以便它可以接收拍卖的响应,因此我们创建一个连接并将其传递给以Main创建Chat对象。joinAuction()创建一个MessageListener设置状态标签的,使用invokeLater()调用来避免阻塞 Smack 库。与Join消息一样,我们不必担心传入消息的内容,因为拍卖目前只能发送一个可能的响应。在此过程中,我们将重命名connectTo()connection()以使代码更易于阅读。

We need to attach the user interface to the chat so it can receive the response from the auction, so we create a connection and pass it to Main to create the Chat object. joinAuction() creates a MessageListener that sets the status label, using an invokeLater() call to avoid blocking the Smack library. As with the Join message, we don’t bother with the contents of the incoming message since there’s only one possible response the auction can send at the moment. While we’re at it, we rename connectTo() to connection() to make the code read better.

公共类 Main {

@SuppressWarnings("unused") 私人聊天 notToBeGCd;

[...]

公共静态 void main(String ... args) 抛出异常 {

Main main = new Main();

main.joinAuction(

连接(args[ARG_HOSTNAME],args[ARG_USERNAME],args[ARG_PASSWORD]),

args[ARG_ITEM_ID]);

}



私人 void joinAuction(XMPPConnection 连接,String itemId)

抛出 XMPPException

{

最终聊天 chat = connection.getChatManager().createChat(

auctionId(itemId,连接),

新 MessageListener() {

公共 void processMessage(聊天 aChat,消息消息) {

SwingUtilities.invokeLater(new Runnable() {

公共 void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

} );

}

});

this.notToBeGCd = 聊天;



聊天。发送消息(新消息());

}

public class Main {

@SuppressWarnings("unused") private Chat notToBeGCd;

[...]

public static void main(String... args) throws Exception {

Main main = new Main();

main.joinAuction(

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]),

args[ARG_ITEM_ID]);

}



private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

final Chat chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new MessageListener() {

public void processMessage(Chat aChat, Message message) {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

});

this.notToBeGCd = chat;



chat.sendMessage(new Message());

}

为什么要有聊天栏?

Why the Chat Field?

图像

您会注意到,我们已将创建的聊天分配给 中notToBeGCd的字段Main。这是为了确保chatJava 运行时不会对 进行垃圾收集。文档顶部有一条ChatManager注释:

You’ll notice that we’ve assigned the chat that we create to the field notToBeGCd in Main. This is to make sure that the chat is not garbage-collected by the Java runtime. There’s a note at the top of the ChatManager documentation that says:

聊天管理器会跟踪所有当前聊天的引用。它不会在内存中单独保存任何引用,因此有必要保存对聊天对象本身的引用。

The chat manager keeps track of references to all current chats. It will not hold any references in memory on its own so it is necessary to keep a reference to the chat object itself.

如果聊天被垃圾回收,Smack 运行时会将消息交给它为此目的创建的新聊天。在交互式应用程序中,我们会监听并显示这些新聊天,但我们的需求不同,所以我们添加了这个特性来阻止这种情况发生。

If the chat is garbage-collected, the Smack runtime will hand the message to a new Chat which it will create for the purpose. In an interactive application, we would listen for and show these new chats, but our needs are different, so we add this quirk to stop it from happening.

我们故意把这个引用弄得很笨拙——为了在代码中强调我们这样做的原因。我们也知道我们很可能在一段时间内想出更好的解决方案。

We made this reference clumsy on purpose—to highlight in the code why we’re doing it. We also know that we’re likely to come up with a better solution in a while.

我们在用户界面中实现了显示方法,最后整个测试通过。

We implement the display method in the user interface and, finally, the whole test passes.

公共类 MainWindow 扩展了 JFrame {

[...]

公共 void showStatus(String status) {

sniperStatus.setText(status);

}

}

public class MainWindow extends JFrame {

[...]

public void showStatus(String status) {

sniperStatus.setText(status);

}

}

笔记

图 11.4可见,该代码可以正常运行。

Figure 11.4 is visible confirmation that the code works.

图 11.4 显示 Lost 状态

Figure 11.4 Showing Lost status

图像

虽然看上去可能没什么,但是它证实了狙击手可以与拍卖建立连接、接受响应并显示结果。

It may not look like much, but it confirms that a Sniper can establish a connection with an auction, accept a response, and display the result.

必要的最低限度

The Necessary Minimum

在他的一份学校报告中,史蒂夫被誉为“对必要最低限​​度的优秀判断者”。他似乎在编写软件方面找到了自己的使命,因为这是零次迭代期间的一项关键技能。

In one of his school reports, Steve was noted as “a fine judge of the necessary minimum.” It seems he’s found his calling in writing software since this is a critical skill during iteration zero.

我们希望您在本章中看到了组装第一个可行骨架所需的专注程度。重点是设计和验证端到端系统的初始结构(端到端包括部署到工作环境),以证明我们选择的包、库和工具确实有效。紧迫感将帮助团队将功能精简到足以测试其假设的绝对最小值。这就是为什么我们没有在狙击手消息中放入任何内容;这会分散我们确保通信和事件处理工作的注意力。我们没有在详细的代码设计上花费太多精力,部分原因是没有太多内容,但主要是因为我们只是将各个部分放在一起;这项工作很快就会到来。

What we hope you’ve seen in this chapter is the degree of focus that’s required to put together your first walking skeleton. The point is to design and validate the initial structure of the end-to-end system—where end-to-end includes deployment to a working environment—to prove that our choices of packages, libraries, and tooling will actually work. A sense of urgency will help the team to strip the functionality down to the absolute minimum sufficient to test their assumptions. That’s why we didn’t put any content in our Sniper messages; it would be a diversion from making sure that the communication and event handling work. We didn’t sweat too hard over the detailed code design, partly because there isn’t much but mainly because we’re just getting the pieces in place; that effort will come soon enough.

当然,您在本章中看到的只是经过编辑的重点内容。我们省略了许多消遣和讨论,因为我们要弄清楚要使用哪些部分以及如何使它们工作,需要仔细查阅产品文档和讨论列表。我们还省略了一些关于这个项目用途的讨论当团队寻找指导其决策的标准时,迭代零通常会带来项目章程问题,因此项目的发起人应该会回答一些关于其目的的深层次问题。

Of course, all you see in this chapter are edited highlights. We’ve left out many diversions and discussions as we figured out which pieces to use and how to make them work, trawling through product documentation and discussion lists. We’ve also left out some of our discussions about what this project is for. Iteration zero usually brings up project chartering issues as the team looks for criteria to guide its decisions, so the project’s sponsors should expect to field some deep questions about its purpose.

我们有一些可见的东西可以作为进步的标志,所以我们可以划掉列表中的第一个项目,如图11.5所示。

We have something visible we can present as a sign of progress, so we can cross off the first item on our list, as in Figure 11.5.

图 11.5 第一件事情完成

Figure 11.5 First item done

图像

下一步是开始构建真正的功能。

The next step is to start building out real functionality.

第 12 章 准备投标

Chapter 12. Getting Ready to Bid

我们编写了一个端到端测试,以便我们可以在拍卖中让狙击手出价。我们开始解释拍卖协议中的消息,并在过程中发现一些新类。我们编写了第一个单元测试,然后重构了一个辅助类。我们描述了这项工作的每一个细节,以表明我们当时的想法。

In which we write an end-to-end test so that we can make the Sniper bid in an auction. We start to interpret the messages in the auction protocol and discover some new classes in the process. We write our first unit tests and then refactor out a helper class. We describe every last detail of this effort to show what we were thinking at the time.

市场介绍

An Introduction to the Market

现在,我们继续使用骨架隐喻,开始充实应用程序。狙击手的核心行为是,当价格发生变化时,它会在拍卖中对物品进行更高的出价。回到我们的待办事项清单,我们重新审视接下来的几项事项:

Now, to continue with the skeleton metaphor, we start to flesh out the application. The core behavior of a Sniper is that it makes a higher bid on an item in an auction when there’s a change in price. Going back to our to-do list, we revisit the next couple of items:

单件商品:加入、出价和放弃。当出现价格时,发送按拍卖定义的最低增量加价的出价。此金额将包含在价格更新信息中。

Single item: join, bid, and lose. When a price comes in, send a bid raised by the minimum increment defined by the auction. This amount will be included in the price update information.

单个物品:加入、竞标并获胜。区分当前哪个竞标者获胜,不要与自己竞标。

Single item: join, bid, and win. Distinguish which bidder is currently winning the auction and don’t bid against ourselves.

我们知道还会有更多内容,但这是一个连贯的功能片段,可以让我们探索设计并展示具体的进展。

We know there’ll be more coming, but this is a coherent slice of functionality that will allow us to explore the design and show concrete progress.

在任何与此类似的分布式系统中,都存在许多有趣的故障和时间问题,但我们的应用程序只需处理协议的客户端。我们依靠底层 XMPP 协议来处理许多常见的分布式编程问题;特别是,我们希望它能够确保竞标者和拍卖之间的消息以发送时的相同顺序到达。

In any distributed system similar to this one there are lots of interesting failure and timing issues, but our application only has to deal with the client side of the protocol. We rely on the underlying XMPP protocol to deal with many common distributed programming problems; in particular, we expect it to ensure that messages between a bidder and an auction arrive in the same order in which they were sent.

正如我们在第 5 章中所述,我们将从验收测试开始下一个功能。我们在上一章中使用了第一个测试来帮助清理应用程序的结构。从现在开始,我们可以使用验收测试来显示增量进度。

As we described in Chapter 5, we start the next feature with an acceptance test. We used our first test in the previous chapter to help flush out the structure of our application. From now on, we can use acceptance tests to show incremental progress.

竞标测试

A Test for Bidding

从测试开始

Starting with a Test

我们编写的每次验收测试都应该包含足够的新需求,以强制实现可控的功能增长,因此我们决定在下一个测试中添加一些价格信息。步骤如下:

Each acceptance test we write should have just enough new requirements to force a manageable increase in functionality, so we decide that the next one will add some price information. The steps are:

1. 告诉拍卖行向狙击手发送价格。

1. Tell the auction to send a price to the Sniper.

2. 检查狙击手是否已收到价格并作出反应。

2. Check the Sniper has received and responded to the price.

3. 检查拍卖是否已收到狙击手增加的出价。

3. Check the auction has received an incremented bid from Sniper.

为了完成这一步骤,狙击手必须区分拍卖中的Price事件Close,显示当前价格,并生成新的出价。我们还必须扩展我们的存根拍卖以处理出价。我们推迟了实现其他同样需要的功能,例如显示狙击手何时赢得拍卖;我们稍后会讲到。以下是新测试:

To make this pass, the Sniper will have to distinguish between Price and Close events from the auction, display the current price, and generate a new bid. We’ll also have to extend our stub auction to handle bids. We’ve deferred implementing other functionality that will also be required, such as displaying when the Sniper has won the auction; we’ll get to that later. Here’s the new test:

公共类 AuctionSniperEndToEndTest {

@Test public void

sniperMakesAHigherBidButLoses() 抛出异常 {

拍卖.startSellingItem();



应用程序.startBiddingIn(拍卖);

拍卖.hasReceivedJoinRequestFromSniper();图像



拍卖.reportPrice(1000, 98, "其他竞标者");图像

应用程序.hasShownSniperIsBidding();图像



拍卖.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);图像



拍卖.announceClosed();图像

应用程序.showsSniperHasLostAuction();

}

}

public class AuctionSniperEndToEndTest {

@Test public void

sniperMakesAHigherBidButLoses() throws Exception {

auction.startSellingItem();



application.startBiddingIn(auction);

auction.hasReceivedJoinRequestFromSniper();



auction.reportPrice(1000, 98, "other bidder");

application.hasShownSniperIsBidding();



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.announceClosed();

application.showsSniperHasLostAuction();

}

}

作为本次测试的一部分,我们有三种新方法要实施。

We have three new methods to implement as part of this test.

图像我们必须等待存根拍卖收到Join请求后才能继续测试。我们使用此断言将狙击手与拍卖同步。

We have to wait for the stub auction to receive the Join request before continuing with the test. We use this assertion to synchronize the Sniper with the auction.

图像这个方法告诉拍卖存根向狙击手发回一条消息,告知他们目前该物品的价格是 1000,下次出价的增量是 98,中标者是“其他竞标者”。

This method tells the stub auction to send a message back to the Sniper with the news that at the moment the price of the item is 1000, the increment for the next bid is 98, and the winning bidder is “other bidder.”

图像该方法要求ApplicationRunner检查狙击手在收到拍卖的价格更新消息后是否显示它正在竞标。

This method asks the ApplicationRunner to check that the Sniper shows that it’s now bidding after it’s received the price update message from the auction.

图像此方法要求存根拍卖检查它是否已收到狙击手的出价,该出价等于最后价格加上最小增量。我们必须做更多的工作,因为 XMPP 层会根据基本标识符构造更长的名称,因此我们定义一个常量,SNIPER_XMPP_ID实际上就是sniper@localhost/Auction

This method asks the stub auction to check that it has received a bid from the Sniper that is equal to the last price plus the minimum increment. We have to do a fraction more work because the XMPP layer constructs a longer name from the basic identifier, so we define a constant SNIPER_XMPP_ID which in practice is sniper@localhost/Auction.

图像我们重复使用了第一次测试中的结束逻辑,因为狙击手仍然输掉了拍卖。

We reuse the closing logic from the first test, as the Sniper still loses the auction.

不切实际的金钱

Unrealistic Money

图像

我们使用整数来表示价值(假设拍卖以日元进行)。在实际系统中,我们将定义一个域类型来表示货币价值,使用固定的十进制实现。在这里,我们简化了表示,使示例代码更容易打印在页面上。

We’re using integers to represent value (imagine that auctions are conducted in Japanese Yen). In a real system, we would define a domain type to represent monetary values, using a fixed decimal implementation. Here, we simplify the representation to make the example code easier to fit onto a printed page.

扩大虚假拍卖

Extending the Fake Auction

我们有两种方法可以写入以FakeAuctionServer支持端到端测试:reportPrice()必须Price通过发送消息chathasReceivedBid()稍微复杂一点——它必须检查拍卖是否从狙击手那里收到了正确的值。我们不会解析传入的消息,而是构造预期的消息并仅比较字符串。我们还从中提取了子句,Matcher以便SingleMessageListenerFakeAuctionServer灵活地定义它将接受的消息。这是第一个版本:

We have two methods to write in the FakeAuctionServer to support the end-to-end test: reportPrice() has to send a Price message through the chat; hasReceivedBid() is a little more complex—it has to check that the auction received the right values from the Sniper. Instead of parsing the incoming message, we construct the expected message and just compare strings. We also pull up the Matcher clause from the SingleMessageListener to give the FakeAuctionServer more flexibility in defining what it will accept as a message. Here’s a first cut:

公共类 FakeAuctionServer { [...]

公共 void reportPrice(int price, int increase, String bidder)

抛出 XMPPException

{

currentChat.sendMessage(

String.format("SOLVersion: 1.1; 事件: PRICE; "

+ "当前价格: %d; 增量: %d; 出价者: %s;",

price, increase, bidder));

}

公共 void hasReceivedJoinRequestFromSniper() 抛出 InterruptedException {

messageListener.receivesAMessage(is(anything()));

}

公共 void hasReceivedBid(int bid, String sniperId)

抛出 InterruptedException

{

assertThat(currentChat.getParticipant(), equalTo(sniperId));

messageListener.receivesAMessage(

equalTo(

String.format("SOLVersion: 1.1; 命令: BID; 价格: %d;", bid)));

}

}

公共类 SingleMessageListener 实现 MessageListener { [...]

@SuppressWarnings("unchecked")

公共 void receivedAMessage(Matcher<? super String> messageMatcher)

抛出 InterruptedException

{

final Message message = messages.poll(5, TimeUnit.SECONDS);

assertThat("Message", message, is(notNullValue()));

assertThat(message.getBody(), messageMatcher);

}

}

public class FakeAuctionServer { [...]

public void reportPrice(int price, int increment, String bidder)

throws XMPPException

{

currentChat.sendMessage(

String.format("SOLVersion: 1.1; Event: PRICE; "

+ "CurrentPrice: %d; Increment: %d; Bidder: %s;",

price, increment, bidder));

}

public void hasReceivedJoinRequestFromSniper() throws InterruptedException {

messageListener.receivesAMessage(is(anything()));

}

public void hasReceivedBid(int bid, String sniperId)

throws InterruptedException

{

assertThat(currentChat.getParticipant(), equalTo(sniperId));

messageListener.receivesAMessage(

equalTo(

String.format("SOLVersion: 1.1; Command: BID; Price: %d;", bid)));

}

}

public class SingleMessageListener implements MessageListener { [...]

@SuppressWarnings("unchecked")

public void receivesAMessage(Matcher<? super String> messageMatcher)

throws InterruptedException

{

final Message message = messages.poll(5, TimeUnit.SECONDS);

assertThat("Message", message, is(notNullValue()));

assertThat(message.getBody(), messageMatcher);

}

}

再看一遍,两种“接收”方法之间存在不平衡。该Join方法比出价消息更宽松,无论是消息内容还是发送者;我们必须记住稍后再回来修复它。在增量开发时,我们推迟了很多决定,但有时一致性和对称性更有意义。我们决定在hasReceivedJoinRequestFromSniper()破解代码的同时改进更多细节。我们还提取消息格式并将它们移动到,Main因为我们需要它们在 Sniper 中构建原始消息。

Looking again, there’s an imbalance between the two “receives” methods. The Join method is much more lax than the bid message, in terms of both the contents of the message and the sender; we will have to remember to come back later and fix it. We defer a great many decisions when developing incrementally, but sometimes consistency and symmetry make more sense. We decide to retrofit more detail into hasReceivedJoinRequestFromSniper() while we have the code cracked open. We also extract the message formats and move them to Main because we’ll need them to construct raw messages in the Sniper.

公共类 FakeAuctionServer { [...]

公共 void hasReceivedJoinRequestFrom(String sniperId)

抛出 InterruptedException

{

接收消息匹配(sniperId,equalTo(Main.JOIN_COMMAND_FORMAT));

}



公共 void hasReceivedBid(int bid,String sniperId)

抛出 InterruptedException

{

接收消息匹配(sniperId,

equalTo(format(Main.BID_COMMAND_FORMAT,bid)));

}



私有 void receivedAMessageMatching(String sniperId,

Matcher<? super String> messageMatcher)

抛出 InterruptedException

{

messageListener.receivesAMessage(messageMatcher);

assertThat(currentChat.getParticipant(),equalTo(sniperId));

}

}

public class FakeAuctionServer { [...]

public void hasReceivedJoinRequestFrom(String sniperId)

throws InterruptedException

{

receivesAMessageMatching(sniperId, equalTo(Main.JOIN_COMMAND_FORMAT));

}



public void hasReceivedBid(int bid, String sniperId)

throws InterruptedException

{

receivesAMessageMatching(sniperId,

equalTo(format(Main.BID_COMMAND_FORMAT, bid)));

}



private void receivesAMessageMatching(String sniperId,

Matcher<? super String> messageMatcher)

throws InterruptedException

{

messageListener.receivesAMessage(messageMatcher);

assertThat(currentChat.getParticipant(), equalTo(sniperId));

}

}

请注意,我们在检查消息内容之后检查狙击手的标识符。这迫使服务器等待消息到达,这意味着它必须接受连接并设置currentChat。否则,测试会因过早检查狙击手的标识符而失败。

Notice that we check the Sniper’s identifier after we check the contents of the message. This forces the server to wait until the message has arrived, which means that it must have accepted a connection and set up currentChat. Otherwise the test would fail by checking the Sniper’s identifier prematurely.

复式记账值

Double-Entry Values

图像

我们使用相同的常量来创建Join消息并检查其内容。通过使用相同的构造,我们可以消除重复并在代码中表达系统两端之间的链接。另一方面,我们很容易犯错,并且没有测试来捕获无效内容。在这种情况下,代码非常简单,几乎任何实现都可以,但是在开发更复杂的东西(例如持久层)时,答案变得不那么确定。我们是否使用相同的框架来写入和读取我们的值?我们能确定它不仅仅是缓存结果,还是值被正确持久化吗?我们是否应该只编写一些直接的数据库查询以确保万无一失?

We’re using the same constant to both create a Join message and check its contents. By using the same construct, we’re removing duplication and expressing in the code a link between the two sides of the system. On the other hand, we’re making ourselves vulnerable to getting them both wrong and not having a test to catch the invalid content. In this case, the code is so simple that pretty much any implementation would do, but the answers become less certain when developing something more complex, such as a persistence layer. Do we use the same framework to write and read our values? Can we be sure that it’s not just caching the results, or that the values are persisted correctly? Should we just write some straight database queries to be sure?

关键问题是,我们认为我们在测试什么?在这里,我们认为通信功能更重要,消息足够简单,因此我们可以依赖字符串常量,并且我们希望能够在 IDE 中找到与消息格式相关的代码。其他开发人员可能会得出不同的结论,并且适合他们的项目。

The critical question is, what do we think we’re testing? Here, we think that the communication features are more important, that the messages are simple enough so we can rely on string constants, and that we’d like to be able to find code related to message formats in the IDE. Other developers might come to a different conclusion and be right for their project.

我们调整端到端测试以匹配新的 API,观察测试失败,然后向 Sniper 添加额外的细节以使测试通过。

We adjust the end-to-end tests to match the new API, watch the test fail, and then add the extra detail to the Sniper to make the test pass.

公共类 AuctionSniperEndToEndTest {

@Test public void

sniperMakesAHigherBidButLoses() 抛出异常 {

拍卖.startSellingItem();



应用程序.startBiddingIn(拍卖);

拍卖.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



拍卖.reportPrice(1000, 98, "其他竞标者");

应用程序.hasShownSniperIsBidding();



拍卖.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



拍卖.announceClosed();

应用程序.showsSniperHasLostAuction();

}

}

公共类 Main { [...]

私有 void joinAuction(XMPPConnection connection, String itemId)

抛出 XMPPException

{

聊天 chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

新的 MessageListener() {

公共 void processMessage(聊天 aChat, 消息消息) {

SwingUtilities.invokeLater(新 Runnable() {

公共 void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

});

this.notToBeGCd = 聊天;

聊天.sendMessage( JOIN_COMMAND_FORMAT );

}

}

public class AuctionSniperEndToEndTest {

@Test public void

sniperMakesAHigherBidButLoses() throws Exception {

auction.startSellingItem();



application.startBiddingIn(auction);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1000, 98, "other bidder");

application.hasShownSniperIsBidding();



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.announceClosed();

application.showsSniperHasLostAuction();

}

}

public class Main { [...]

private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

Chat chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new MessageListener() {

public void processMessage(Chat aChat, Message message) {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

});

this.notToBeGCd = chat;

chat.sendMessage(JOIN_COMMAND_FORMAT);

}

}

意外的失败

A Surprise Failure

最后,我们编写“检查”方法来ApplicationRunner给出第一个失败结果。实现很简单:我们只需添加另一个状态常量并复制现有方法。

Finally we write the “checking” method on the ApplicationRunner to give us our first failure. The implementation is simple: we just add another status constant and copy the existing method.

公共类 ApplicationRunner { [...]

公共 void hasShownSniperIsBidding() {

驱动程序。显示SniperStatus(MainWindow.STATUS_BIDDING);

}



公共 void showsSniperHasLostAuction() {

驱动程序。显示SniperStatus(MainWindow.STATUS_LOST);

}

}

public class ApplicationRunner { [...]

public void hasShownSniperIsBidding() {

driver.showsSniperStatus(MainWindow.STATUS_BIDDING);

}



public void showsSniperHasLostAuction() {

driver.showsSniperStatus(MainWindow.STATUS_LOST);

}

}

我们期望看到有关缺少标签文本的信息,但我们得到的却是这样的:

We’re expecting to see something about a missing label text but instead we get this:

java.lang.AssertionError:

预期:不为空,

得到:空

[...]

在 auctionsniper.SingleMessageListener.receivesAMessage()

在 auctionsniper.FakeAuctionServer.hasReceivedJoinRequestFromSniper()

在 auctionsniper.AuctionSniperEndToEndTest.sniperMakesAHigherBid()

[...]

java.lang.AssertionError:

Expected: is not null

got: null

[...]

at auctionsniper.SingleMessageListener.receivesAMessage()

at auctionsniper.FakeAuctionServer.hasReceivedJoinRequestFromSniper()

at auctionsniper.AuctionSniperEndToEndTest.sniperMakesAHigherBid()

[...]

错误流上有这样的内容:

and this on the error stream:

冲突(409)

在 jivesoftware.smack.SASLAuthentication.bindResourceAndEstablishSession()

在 jivesoftware.smack.SASLAuthentication.authenticate()

在 jivesoftware.smack.XMPPConnection.login()

在 jivesoftware.smack.XMPPConnection.login()

在 auctionsniper.Main.connection()

在 auctionsniper.Main.main()

conflict(409)

at jivesoftware.smack.SASLAuthentication.bindResourceAndEstablishSession()

at jivesoftware.smack.SASLAuthentication.authenticate()

at jivesoftware.smack.XMPPConnection.login()

at jivesoftware.smack.XMPPConnection.login()

at auctionsniper.Main.connection()

at auctionsniper.Main.main()

经过一番调查,我们意识到发生了什么。我们引入了第二个测试,该测试尝试使用与第一个测试相同的帐户和资源名称进行连接。与 Southabee 的 On-Line 一样,服务器配置为拒绝多个打开的连接,因此第二个测试失败,因为服务器认为第一个测试仍处于连接状态。在生产中,我们的应用程序可以工作,因为我们会在关闭时停止整个过程,这会断开连接。我们的小妥协(在新的线程中启动应用程序)让我们措手不及。这里正确的做法是添加一个回调,以便在我们关闭窗口时断开客户端连接,以便应用程序在关闭后自行清理:

After some investigation we realize what’s happened. We’ve introduced a second test which tries to connect using the same account and resource name as the first. The server is configured, like Southabee’s On-Line, to reject multiple open connections, so the second test fails because the server thinks that the first is still connected. In production, our application would work because we’d stop the whole process when closing, which would break the connection. Our little compromise (of starting the application in a new thread) has caught us out. The Right Thing to do here is to add a callback to disconnect the client when we close the window so that the application will clean up after itself:

公共类 Main { [...]

private void joinAuction (XMPPConnection connection,String itemId)

抛出 XMPPException

{

disconnectWhenUICloses(connection);

聊天 chat = connection.getChatManager().createChat(

[...]

chat.sendMessage(JOIN_COMMAND_FORMAT);

}

private void disconnectWhenUICloses(final XMPPConnection connection) {

ui.addWindowListener(new WindowAdapter() {

@Override public void windowClosed(WindowEvent e) {

connection.disconnect();

}

});

}

}

public class Main { [...]

private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

disconnectWhenUICloses(connection);

Chat chat = connection.getChatManager().createChat(

[...]

chat.sendMessage(JOIN_COMMAND_FORMAT);

}

private void disconnectWhenUICloses(final XMPPConnection connection) {

ui.addWindowListener(new WindowAdapter() {

@Override public void windowClosed(WindowEvent e) {

connection.disconnect();

}

});

}

}

现在我们得到了预期的失败,因为狙击手无法开始竞标。

Now we get the failure we expected, because the Sniper has no way to start bidding.

java.lang.AssertionError:试图 在所有顶层窗口中

查找...恰好 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)中的

1 个 JLabel(名称为“sniper status”),并检查其标签文本是否为“Bidding”,但... 所有顶层窗口均 包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上) 包含 1 个 JLabel(名称为“sniper status”)标签文本为“Lost” [...]在 auctionsniper.AuctionSniperDriver.showsSniperStatus() 在 auctionsniper.ApplicationRunner.hasShownSniperIsBidding ()在 auctionsniper.AuctionSniperEndToEndTest.sniperMakesAHigherBidButLoses()























java.lang.AssertionError:

Tried to look for...

exactly 1 JLabel (with name "sniper status")

in exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

and check that its label text is "Bidding"

but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JLabel (with name "sniper status")

label text was "Lost"

[...]

at auctionsniper.AuctionSniperDriver.showsSniperStatus()

at auctionsniper.ApplicationRunner.hasShownSniperIsBidding()

at auctionsniper.AuctionSniperEndToEndTest.sniperMakesAHigherBidButLoses()

由外而内的开发

Outside-In Development

这次失败定义了我们下一次编码阶段的目标。它从高层次上告诉我们我们的目标是什么——我们只需填写实现,直到它通过。

This failure defines the target for our next coding episode. It tells us, at a high level, what we’re aiming for—we just have to fill in implementation until it passes.

我们的测试驱动开发方法是从触发我们想要实现的行为的外部事件开始,然后一次将一个对象写入代码,直到我们达到可见的效果(例如发送消息或日志条目),表明我们已实现目标。端到端测试向我们展示了该过程的终点,因此我们可以探索中间的空间。

Our approach to test-driven development is to start with the outside event that triggers the behavior we want to implement and work our way into the code an object at a time, until we reach a visible effect (such as a sent message or log entry) indicating that we’ve achieved our goal. The end-to-end test shows us the end points of that process, so we can explore our way through the space in the middle.

在以下部分中,我们将构建实现拍卖狙击手所需的类型。我们将严格遵循 TDD 规则,慢慢介绍该流程的工作原理。在实际项目中,我们有时会提前设计一些内容,以了解整体情况,但大多数时候我们实际上就是这么做的。它会产生正确的结果,并迫使我们提出正确的问题。

In the following sections, we build up the types we need to implement our Auction Sniper. We’ll take it slowly, strictly by the TDD rules, to show how the process works. In real projects, we sometimes design a bit further ahead to get a sense of the bigger picture, but much of the time this is what we actually do. It produces the right results and forces us to ask the right questions.

无限关注细节?

Infinite Attention to Detail?

我们之所以发现资源冲突,是因为我们的服务器配置与 Southabee 的 On-Line 相匹配,无论是幸运还是明智。我们可能使用了另一种设置,允许新连接启动现有连接,这会导致测试通过,但错误流中会出现来自 Smack 库的令人困惑的冲突消息。这在开发中效果很好,但在生产中存在狙击手开始失败的风险。

We caught the resource clash because, by luck or insight, our server configuration matched that of Southabee’s On-Line. We might have used an alternative setting which allows new connections to kick off existing ones, which would have resulted in the tests passing but with a confusing conflict message from the Smack library on the error stream. This would have worked fine in development, but with a risk of Snipers starting to fail in production.

我们怎么能指望捕捉到整个系统中的所有配置选项呢?在某种程度上我们做不到,而这正是专业测试人员的核心工作。我们能做的就是尽早尽可能多地测试系统,并反复这样做。我们还可以通过保持组件的高质量和不断简化来帮助自己应对整个系统的复杂性。如果这听起来很昂贵,请考虑在繁忙的生产系统中查找和修复像这样的暂时性错误的成本。

How can we hope to catch all the configuration options in an entire system? At some level we can’t, and this is at the heart of what professional testers do. What we can do is push to exercise as much as possible of the system as early as possible, and to do so repeatedly. We can also help ourselves cope with total system complexity by keeping the quality of its components high and by constantly pushing to simplify. If that sounds expensive, consider the cost of finding and fixing a transient bug like this one in a busy production system.

拍卖信息翻译器

The AuctionMessageTranslator

梳理出一个新的类别

Teasing Out a New Class

我们进入狙击手的入口点是通过 Smack 库从拍卖会接收消息的地方:它是触发我们想要进行的下一轮行为的事件。实际上,这意味着我们需要一个实现的类MessageListener来附加到Chat。当这个类从拍卖会收到原始消息时,它会将其转换为代表我们代码中的拍卖事件的内容,最终会促使狙击手采取行动并改变用户界面。

Our entry point to the Sniper is where we receive a message from the auction through the Smack library: it’s the event that triggers the next round of behavior we want to make work. In practice, this means that we need a class implementing MessageListener to attach to the Chat. When this class receives a raw message from the auction, it will translate it into something that represents an auction event within our code which, eventually, will prompt a Sniper action and a change in the user interface.

我们已经有这样一个类了Main——它是匿名的,而且它的职责不是很明显:

We already have such a class in Main—it’s anonymous and its responsibilities aren’t very obvious:

新的 MessageListener() {

public void processMessage(Chat aChat,Message message) {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

}

new MessageListener() {

public void processMessage(Chat aChat, Message message) {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

}

此代码隐式接受一条Close消息(目前我们拥有的唯一类型的消息)并实现狙击手的响应。在添加更多功能之前,我们希望明确说明这种情况。我们首先将匿名类提升为顶级类,这意味着它需要一个名称。从上一段的描述中,我们选择了“翻译”一词并将其称为AuctionMessageTranslator,因为它将翻译来自拍卖的消息

This code implicitly accepts a Close message (the only kind of message we have so far) and implements the Sniper’s response. We’d like to make this situation explicit before we add more features. We start by promoting the anonymous class to a top-level class in its own right, which means it needs a name. From our description in the paragraph above, we pick up the word “translate” and call it an AuctionMessageTranslator, because it will translate messages from the auction.

问题是,当前匿名类ui从 中选取字段Main。我们必须将某些内容附加到我们新提升的类,以便它能够响应消息。最明显的做法是将其传递给它,MainWindow但我们不愿意创建对用户界面组件的依赖。这会使单元测试变得困难,因为我们必须查询在 Swing 事件线程中运行的组件的状态。

The catch is that the current anonymous class picks up the ui field from Main. We’ll have to attach something to our newly promoted class so that it can respond to a message. The most obvious thing to do is pass it the MainWindow but we’re unhappy about creating a dependency on a user interface component. That would make it hard to unit-test, because we’d have to query the state of a component that’s running in the Swing event thread.

更重要的是,这种依赖关系会破坏“单一责任”原则,该原则规定,一个类只需解压拍卖中的原始消息就足够了,而无需知道如何呈现狙击手状态。正如我们在“可维护性设计”(第47页)中所写,我们希望保持关注点分离

More significantly, such a dependency would break the “single responsibility” principle which says that unpacking raw messages from the auction is quite enough for one class to do, without also having to know how to present the Sniper status. As we wrote in “Designing for Maintainability” (page 47), we want to maintain a separation of concerns.

鉴于这些限制,我们决定我们的 newAuctionMessageTranslator将把解释事件的处理委托给一个协作者,我们将用一个AuctionEventListener接口来表示它;我们可以在构造时将实现它的对象传递给翻译器。我们还不知道这个接口里有什么,我们还没有开始考虑它的实现。我们最关心的是让消息翻译工作起来;其余的可以等一等。到目前为止,设计看起来像图 12.1(属于外部框架的类型,例如Chat,用阴影表示):

Given these constraints, we decide that our new AuctionMessageTranslator will delegate the handling of an interpreted event to a collaborator, which we will represent with an AuctionEventListener interface; we can pass an object that implements it into the translator on construction. We don’t yet know what’s in this interface and we haven’t yet begun to think about its implementation. Our immediate concern is to get the message translation to work; the rest can wait. So far the design looks like Figure 12.1 (types that belong to external frameworks, such as Chat, are shaded):

12.1 AuctionMessageTranslator

Figure 12.1 The AuctionMessageTranslator

图像

第一个单元测试

The First Unit Test

我们从更简单的事件类型开始。正如我们所见,Close事件没有值 — 它是一个简单的触发器。当翻译器收到一个事件时,我们希望它能够适当地调用其侦听器。

We start with the simpler event type. As we’ve seen, a Close event has no values—it’s a simple trigger. When the translator receives one, we want it to call its listener appropriately.

因为这是我们的第一个单元测试,所以我们将非常缓慢地构建它以展示该过程(稍后,我们将加快速度)。我们从测试方法名称开始。JUnit 通过反射获取测试方法,因此我们可以根据需要将它们的名称写得尽可能长和具有描述性,因为我们永远不必将它们包含在代码中。第一个测试表明,当翻译器收到原始Close消息时,它将告诉正在收听的任何人拍卖已结束。

As this is our first unit test, we’ll build it up very slowly to show the process (later, we will move faster). We start with the test method name. JUnit picks up test methods by reflection, so we can make their names as long and descriptive as we like because we never have to include them in code. The first test says that the translator will tell anything that’s listening that the auction has closed when it receives a raw Close message.

包测试.auctionsniper;



公共类 AuctionMessageTranslatorTest {

@Test public void

notifiesAuctionClosedWhenCloseMessageReceived() {

// 尚无任何内容

}

}

package test.auctionsniper;



public class AuctionMessageTranslatorTest {

@Test public void

notifiesAuctionClosedWhenCloseMessageReceived() {

// nothing yet

}

}

将测试放在不同的包中

Put Tests in a Different Package

图像

我们养成了将测试放在与测试代码不同的包中的习惯。我们希望确保像任何其他客户端一样通过其公共接口来驱动代码,而不是为测试打开一个包范围的后门。我们还发现,随着应用程序和测试代码的增长,单独的包使现代 IDE 中的导航更加容易。

We’ve adopted a habit of putting tests in a different package from the code they’re exercising. We want to make sure we’re driving the code through its public interfaces, like any other client, rather than opening up a package-scoped back door for testing. We also find that, as the application and test code grows, separate packages make navigation in modern IDEs easier.

下一步是添加触发我们要测试的行为的操作 — 在本例中是发送Close消息。我们已经知道这会是什么样子,因为它是对 SmackMessageListener接口的调用。

The next step is to add the action that will trigger the behavior we want to test—in this case, sending a Close message. We already know what this will look like since it’s a call to the Smack MessageListener interface.

公共类 AuctionMessageTranslatorTest {

公共静态最终聊天 UNUSED_CHAT = null;

私人最终 AuctionMessageTranslator 翻译器 =

新 AuctionMessageTranslator();

@Test 公共 void

notfiesAuctionClosedWhenCloseMessageReceived() {

消息消息 = 新消息();

消息.setBody("SOLVersion: 1.1; 事件: CLOSE;");



翻译器.processMessage(UNUSED_CHAT, 消息);

}

}

public class AuctionMessageTranslatorTest {

public static final Chat UNUSED_CHAT = null;

private final AuctionMessageTranslator translator =

new AuctionMessageTranslator();

@Test public void

notfiesAuctionClosedWhenCloseMessageReceived() {

Message message = new Message();

message.setBody("SOLVersion: 1.1; Event: CLOSE;");



translator.processMessage(UNUSED_CHAT, message);

}

}

null当争论不重要时使用

Use null When an Argument Doesn’t Matter

图像

UNUSED_CHAT是一个定义为 的常量的有意义的名称null。我们将其传递给processMessage()而不是实际Chat对象,因为该类Chat很难实例化——它的构造函数是包范围的,我们必须填写一系列依赖项才能创建一个。碰巧的是,我们当前功能不需要它,所以我们只传递一个null值来满足编译器的要求,但使用命名常量来明确其重要性。

UNUSED_CHAT is a meaningful name for a constant that is defined as null. We pass it into processMessage() instead of a real Chat object because the Chat class is difficult to instantiate—its constructor is package-scoped and we’d have to fill in a chain of dependencies to create one. As it happens, we don’t need one anyway for the current functionality, so we just pass in a null value to satisfy the compiler but use a named constant to make clear its significance.

需要明确的是,这null不是一个空对象 [Woolf98],它可以被调用,但不会做出任何响应。这null只是一个占位符,如果在测试期间调用,则会失败。

To be clear, this null is not a null object [Woolf98] which may be called and will do nothing in response. This null is just a placeholder and will fail if called during the test.

我们从接口生成一个骨架实现MessageListener

We generate a skeleton implementation from the MessageListener interface.

package auctionsniper;



public class AuctionMessageTranslator implements MessageListener {

public void processMessage(Chat chat, Message message) {

// TODO 在此处填写

}

}

package auctionsniper;



public class AuctionMessageTranslator implements MessageListener {

public void processMessage(Chat chat, Message message) {

// TODO Fill in here

}

}

接下来,我们需要检查翻译是否已完成 — 由于我们尚未实现任何功能,因此该检查应该会失败。我们已经决定希望翻译器在事件Close发生时通知其侦听器,因此我们将在测试中描述该预期行为。

Next, we want a check that shows whether the translation has taken place—which should fail since we haven’t implemented anything yet. We’ve already decided that we want our translator to notify its listener when the Close event occurs, so we’ll describe that expected behavior in our test.

@RunWith(JMock.class)

public class AuctionMessageTranslatorTest {

private final Mockery context = new Mockery();

private final AuctionEventListener listener =

context.mock(AuctionEventListener.class);

private final AuctionMessageTranslator translator =

new AuctionMessageTranslator();



@Test public void

notfiesAuctionClosedWhenCloseMessageReceived() {

context.checking(new Expectations() {{

oneOf(listener).auctionClosed();

}});



Message message = new Message();

message.setBody("SOLVersion: 1.1; 事件: CLOSE;");



translator.processMessage(UNUSED_CHAT, message);

}

}

@RunWith(JMock.class)

public class AuctionMessageTranslatorTest {

private final Mockery context = new Mockery();

private final AuctionEventListener listener =

context.mock(AuctionEventListener.class);

private final AuctionMessageTranslator translator =

new AuctionMessageTranslator();



@Test public void

notfiesAuctionClosedWhenCloseMessageReceived() {

context.checking(new Expectations() {{

oneOf(listener).auctionClosed();

}});



Message message = new Message();

message.setBody("SOLVersion: 1.1; Event: CLOSE;");



translator.processMessage(UNUSED_CHAT, message);

}

}

这或多或少是我们在第 2 章末尾描述的那种单元测试,因此我们不会在这里再次讨论其结构,只是要强调突出显示的期望行。这是测试中最重要的一行,我们声明了转换器对其环境的影响的重要性。它表示,当我们向转换器发送适当的消息时,我们希望它只调用auctionClosed()一次侦听器的方法。

This is more or less the kind of unit test we described at the end of Chapter 2, so we won’t go over its structure again here except to emphasize the highlighted expectation line. This is the most significant line in the test, our declaration of what matters about the translator’s effect on its environment. It says that when we send an appropriate message to the translator, we expect it to call the listener’s auctionClosed() method exactly once.

我们遇到了失败,表明我们还没有实现我们需要的行为:

We get a failure that shows that we haven’t implemented the behavior we need:

并非所有预期都得到满足

预期:

!预期一次,从未调用:auctionEventListener.auctionClosed()

在此之前发生了什么:什么都没有!

在 org.jmock.Mockery.assertIsSatisfied(Mockery.java:199)

[...]

在 org.junit.internal.runners.JUnit4ClassRunner.run()

not all expectations were satisfied

expectations:

! expected once, never invoked: auctionEventListener.auctionClosed()

what happened before this: nothing!

at org.jmock.Mockery.assertIsSatisfied(Mockery.java:199)

[...]

at org.junit.internal.runners.JUnit4ClassRunner.run()

关键短语是这样的:

The critical phrase is this one:

预期一次,从未调用:auctionEventListener.auctionClosed()

expected once, never invoked: auctionEventListener.auctionClosed()

这告诉我们我们没有像应该的那样呼叫听众。

which tells us that we haven’t called the listener as we should have.

我们需要做两件事才能使测试通过。首先,我们需要连接转换器和侦听器,以便它们可以通信。我们决定将侦听器传递到转换器的构造函数中;这很简单,并确保转换器始终正确设置侦听器 — Java 类型系统不会让我们忘记。测试设置如下所示:

We need to do two things to make the test pass. First, we need to connect the translator and listener so that they can communicate. We decide to pass the listener into the translator’s constructor; it’s simple and ensures that the translator is always set up correctly with a listener—the Java type system won’t let us forget. The test setup looks like this:

公共类 AuctionMessageTranslatorTest {

私有最终 Mockery 上下文 = 新 Mockery();

私有最终 AuctionEventListener 监听器 =

上下文.mock(AuctionEventListener.class);

私有最终 AuctionMessageTranslator 翻译器 =

新 AuctionMessageTranslator(监听器);

public class AuctionMessageTranslatorTest {

private final Mockery context = new Mockery();

private final AuctionEventListener listener =

context.mock(AuctionEventListener.class);

private final AuctionMessageTranslator translator =

new AuctionMessageTranslator(listener);

我们需要做的第二件事是调用该auctionClosed()方法。实际上,这就是让这个测试通过所需要做的全部工作,因为我们还没有定义任何其他行为。

The second thing we need to do is call the auctionClosed() method. Actually, that’s all we need to do to make this test pass, since we haven’t defined any other behavior.

public void processMessage(Chat chat, Message message) {

listener.auctionClosed();

}

public void processMessage(Chat chat, Message message) {

listener.auctionClosed();

}

测试通过。这可能感觉像是作弊,因为我们实际上并没有解开消息。我们所做的就是找出各个部分的位置并将它们放入测试工具中 — 并锁定一个功能,该功能在我们添加更多功能时应该会继续工作。

The test passes. This might feel like cheating since we haven’t actually unpacked a message. What we have done is figured out where the pieces are and got them into a test harness—and locked down one piece of functionality that should continue to work as we add more features.

简化测试设置

Simplified Test Setup

图像

您可能已经注意到,测试类中的所有字段都是final。正如我们在第 3 章中所述,JUnit 为每个测试方法创建一个新的测试类实例,因此会为每个测试方法重新创建字段。我们利用这一点,将尽可能多的字段声明为,final并在构造期间初始化它们,从而清除任何循环依赖关系。Steve 喜欢将其形象地想象为创建一个对象格,充当支持测试的框架。

You might have noticed that all the fields in the test class are final. As we described in Chapter 3, JUnit creates a new instance of the test class for each test method, so the fields are recreated for each test method. We exploit this by declaring as many fields as possible as final and initializing them during construction, which flushes out any circular dependencies. Steve likes to think of this visually as creating a lattice of objects that acts a frame to support the test.

有时,正如您将在本例后面看到的那样,我们无法锁定所有内容,而必须直接附加依赖项,但大多数时候我们可以这样做。任何异常都会引起我们的注意,并突出显示可能的依赖项循环。另一方面,NUnit 重用了测试类的相同实例,因此在这种情况下,我们必须明确更新任何支持的测试值和对象。

Sometimes, as you’ll see later in this example, we can’t lock everything down and have to attach a dependency directly, but most of the time we can. Any exceptions will attract our attention and highlight a possible dependency loop. NUnit, on the other hand, reuses the same instance of the test class, so in that case we’d have to renew any supporting test values and objects explicitly.

关闭用户界面循环

Closing the User Interface Loop

现在我们已经有了新组件的雏形,我们可以将其改造到 Sniper 中,以确保我们不会偏离工作代码太远。之前,Main我们更新了 Sniper 用户界面,所以现在我们让它实现AuctionEventListener并将功能移到新auctionClosed()方法中。

Now we have the beginnings of our new component, we can retrofit it into the Sniper to make sure we don’t drift too far from working code. Previously, Main updated the Sniper user interface, so now we make it implement AuctionEventListener and move the functionality to the new auctionClosed() method.

公共类 Main实现 AuctionEventListener { [...]



私有 void joinAuction(XMPPConnection connection, String itemId)

抛出 XMPPException

{

disconnectWhenUICloses(connection);



聊天 chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new AuctionMessageTranslator(this));

chat.sendMessage(JOIN_COMMAND_FORMAT);

notToBeGCd = chat;

}



公共 void auctionClosed() {

SwingUtilities.invokeLater(new Runnable() {

公共 void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

}

public class Main implements AuctionEventListener { [...]



private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

disconnectWhenUICloses(connection);



Chat chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new AuctionMessageTranslator(this));

chat.sendMessage(JOIN_COMMAND_FORMAT);

notToBeGCd = chat;

}



public void auctionClosed() {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

}

该结构现在看起来像图 12.2

The structure now looks like Figure 12.2.

图 12.2 介绍 AuctionMessageTranslator

Figure 12.2 Introducing the AuctionMessageTranslator

图像

我们取得了什么成就?

What Have We Achieved?

在这一小步中,我们将应用程序的单个功能提取到一个单独的类中,这意味着该功能现在有了名称并且可以进行单元测试。我们还让它变得Main更简单,因为它不再涉及解释拍卖消息的文本。这还不是什么大问题,但随着 Sniper 应用程序的发展,我们将展示这种方法如何帮助我们保持代码的整洁和灵活,以及组件之间的职责和关系清晰。

In this baby step, we’ve extracted a single feature of our application into a separate class, which means the functionality now has a name and can be unit-tested. We’ve also made Main a little simpler, now that it’s no longer concerned with interpreting the text of messages from the auction. This is not yet a big deal but we will show, as the Sniper application grows, how this approach helps us keep code clean and flexible, with clear responsibilities and relationships between its components.

解读价格信息

Unpacking a Price Message

介绍消息事件类型

Introducing Message Event Types

我们即将介绍第二种拍卖消息类型,即当前价格更新。狙击手需要区分这两种消息类型,因此我们再次查看第 9 章中Southabee's On-Line 发送给我们的消息格式。它们很简单 — 仅一行,包含几个名称/值对。以下是格式的示例:

We’re about to introduce a second auction message type, the current price update. The Sniper needs to distinguish between the two, so we take another look at the message formats in Chapter 9 that Southabee’s On-Line have sent us. They’re simple—just a single line with a few name/value pairs. Here are examples for the formats again:

SOLVersion:1.1;事件:价格;当前价格:192;增量:7;投标人:其他人;

SOLVersion:1.1;事件:CLOSE;

SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7; Bidder: Someone else;

SOLVersion: 1.1; Event: CLOSE;

起初,作为面向对象爱好者,我们尝试将这些消息建模为类型,但我们对行为不够清楚,无法证明任何有意义的结构,所以我们放弃了这个想法。我们决定从一个简单的解决方案开始,然后从那里进行调整。

At first, being object-oriented enthusiasts, we try to model these messages as types, but we’re not clear enough about the behavior to justify any meaningful structure, so we back off the idea. We decide to start with a simplistic solution and adapt from there.

第二次考验

The Second Test

在我们的第二个测试中引入不同的Price事件将迫使我们解析传入的消息。此测试具有与第一个测试相同的结构,但获取不同的输入字符串并期望我们在侦听器上调用不同的方法。Price消息包含上次出价的详细信息,我们需要将其解包并传递给侦听器,因此我们将它们包含在新方法的签名中currentPrice()。这是测试:

The introduction of a different Price event in our second test will force us to parse the incoming message. This test has the same structure as the first one but gets a different input string and expects us to call a different method on the listener. A Price message includes details of the last bid, which we need to unpack and pass to the listener, so we include them in the signature of the new method currentPrice(). Here’s the test:

@Test public void

notifiesBidDetailsWhenCurrentPriceMessageReceived() {

context.checking(new Expectations() {{

exact(1).of(listener).currentPrice(192, 7);

});



Message message = new Message();

message.setBody(

"SOLVersion: 1.1; 事件: PRICE; CurrentPrice: 192; 增量: 7; 投标人: 其他人;"

);



translator.processMessage(UNUSED_CHAT, message);

}

@Test public void

notifiesBidDetailsWhenCurrentPriceMessageReceived() {

context.checking(new Expectations() {{

exactly(1).of(listener).currentPrice(192, 7);

}});



Message message = new Message();

message.setBody(

"SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7; Bidder: Someone else;"

);



translator.processMessage(UNUSED_CHAT, message);

}

为了通过编译器,我们向监听器添加了一个方法;这只需要在 IDE 中按一下键即可:1

To get through the compiler, we add a method to the listener; this takes just a keystroke in the IDE:1

1.现代开发环境,例如 Eclipse 和 IDEA,会根据请求填充缺失的方法。这意味着我们可以编写想要进行的调用,然后让工具为我们填充声明。

1. Modern development environments, such as Eclipse and IDEA, will fill in a missing method on request. This means that we can write the call we’d like to make and ask the tool to fill in the declaration for us.

公共接口AuctionEventListener {

void auctionClosed();

void currentPrice(int price, int increase);

}

public interface AuctionEventListener {

void auctionClosed();

void currentPrice(int price, int increment);

}

测试失败。

The test fails.

意外调用:auctionEventListener.auctionClosed()

期望:

!预期一次,从未调用:auctionEventListener.currentPrice(<192>, <7>)

在此之前发生了什么:什么都没有!

[...]

在 $Proxy6.auctionClosed()

在 auctionsniper.AuctionMessageTranslator.processMessage()

在 AuctionMessageTranslatorTest.translatesPriceMessagesAsAuctionPriceEvents()

[...]

在 JUnit4ClassRunner.run(JUnit4ClassRunner.java:42)

unexpected invocation: auctionEventListener.auctionClosed()

expectations:

! expected once, never invoked: auctionEventListener.currentPrice(<192>, <7>)

what happened before this: nothing!

[...]

at $Proxy6.auctionClosed()

at auctionsniper.AuctionMessageTranslator.processMessage()

at AuctionMessageTranslatorTest.translatesPriceMessagesAsAuctionPriceEvents()

[...]

at JUnit4ClassRunner.run(JUnit4ClassRunner.java:42)

这次的关键短语是:

This time the critical phrase is:

意外调用:auctionEventListener.auctionClosed()

unexpected invocation: auctionEventListener.auctionClosed()

这意味着代码auctionClosed()在测试期间调用了错误的方法。没有预料到这个调用,所以它立即失败,并在堆栈跟踪中向我们显示触发失败的行(您可以在行中Mockery看到 的工作原理,它是实际的运行时替代)。 在这里,代码失败的地方很明显,所以我们可以修复它。Mockery$Proxy6.auctionClosed()AuctionEventListener

which means that the code called the wrong method, auctionClosed(), during the test. The Mockery isn’t expecting this call so it fails immediately, showing us in the stack trace the line that triggered the failure (you can see the workings of the Mockery in the line $Proxy6.auctionClosed() which is the runtime substitute for a real AuctionEventListener). Here, the place where the code failed is obvious, so we can just fix it.

我们的第一个版本虽然很粗糙,但它通过了测试。

Our first version is rough, but it passes the test.

公共类AuctionMessageTranslator实现MessageListener {

私有最终AuctionEventListener监听器;



公共AuctionMessageTranslator(AuctionEventListener监听器){

this.listener = listener;

}



公共void processMessage(Chat chat,Message message){

HashMap <String,String> event = unpackEventFrom(message);



String type = event.get(“事件”);

如果(“CLOSE” .equals(type)){

listener.auctionClosed();

} else if(“PRICE” .equals(type)){

listener.currentPrice(Integer.parseInt(event.get(“CurrentPrice”)),

Integer.parseInt(event.get(“Increment”)));

}

}



私有HashMap <String,String> unpackEventFrom(Message message){

HashMap <String,String> event = new HashMap <String,String>();

对于(字符串元素:message.getBody()。split(“;”)){

String [] pair = element.split(“:”);

事件.put(pair [0] .trim(),pair [1] .trim());

}

返回事件;

}

}

public class AuctionMessageTranslator implements MessageListener {

private final AuctionEventListener listener;



public AuctionMessageTranslator(AuctionEventListener listener) {

this.listener = listener;

}



public void processMessage(Chat chat, Message message) {

HashMap<String, String> event = unpackEventFrom(message);



String type = event.get("Event");

if ("CLOSE".equals(type)) {

listener.auctionClosed();

} else if ("PRICE".equals(type)) {

listener.currentPrice(Integer.parseInt(event.get("CurrentPrice")),

Integer.parseInt(event.get("Increment")));

}

}



private HashMap<String, String> unpackEventFrom(Message message) {

HashMap<String, String> event = new HashMap<String, String>();

for (String element : message.getBody().split(";")) {

String[] pair = element.split(":");

event.put(pair[0].trim(), pair[1].trim());

}

return event;

}

}

此实现将消息正文分解为一组键/值对,它将其解释为拍卖事件,以便通知AuctionEventListener。我们还必须修复FakeAuctionServer发送真实Close事件而不是当前的空消息,否则端到端测试将错误失败。

This implementation breaks the message body into a set of key/value pairs, which it interprets as an auction event so it can notify the AuctionEventListener. We also have to fix the FakeAuctionServer to send a real Close event rather than the current empty message, otherwise the end-to-end tests will fail incorrectly.

public void advertiseClosed() 抛出 XMPPException {

currentChat.sendMessage("SOLVersion: 1.1; 事件: CLOSE;");

}

public void announceClosed() throws XMPPException {

currentChat.sendMessage("SOLVersion: 1.1; Event: CLOSE;");

}

再次运行端到端测试提醒我们仍在开发竞价功能。测试显示狙击手状态标签仍显示,Joining而不是Bidding

Running our end-to-end test again reminds us that we’re still working on the bidding feature. The test shows that the Sniper status label still displays Joining rather than Bidding.

发现进一步的工作

Discovering Further Work

此代码通过了单元测试,但缺少了一些东西。它假设消息结构正确且版本正确。考虑到消息将来自外部系统,这感觉很危险,所以我们需要添加一些错误处理。我们不想打断功能运行的流程,所以我们将错误处理添加到待办事项列表中,以便稍后再回来查看(图 12.3)。

This code passes the unit test, but there’s something missing. It assumes that the message is correctly structured and has the right version. Given that the message will be coming from an outside system, this feels risky, so we need to add some error handling. We don’t want to break the flow of getting features to work, so we add error handling to the to-do list to come back to it later (Figure 12.3).

图 12.3 添加处理错误的任务

Figure 12.3 Added tasks for handling errors

图像

我们还担心,翻译器对它正在做的事情不够清楚,它的解析和调度活动混杂在一起。我们计划在通过验收测试后立即解决这个类,而验收测试已经不远了。

We’re also concerned that the translator is not as clear as it could be about what it’s doing, with its parsing and the dispatching activities mixed together. We make a note to address this class as soon as we’ve passed the acceptance test, which isn’t far off.

完成工作

Finish the Job

本章的大部分工作都是试图决定我们想要说什么以及如何表达:我们编写一个高级端到端测试来描述狙击手应该实现什么;我们编写长单元测试名称来告诉我们一个类的作用;我们提取新类来梳理功能的细粒度方面;我们编写许多小方法来使每一层代码保持一致的抽象级别。但首先,我们编写一个粗略的实现来证明我们知道如何让代码完成所需的工作,然后我们进行重构——我们将在下一章中进行重构。

Most of the work in this chapter has been trying to decide what we want to say and how to say it: we write a high-level end-to-end test to describe what the Sniper should implement; we write long unit test names to tell us what a class does; we extract new classes to tease apart fine-grained aspects of the functionality; and we write lots of little methods to keep each layer of code at a consistent level of abstraction. But first, we write a rough implementation to prove that we know how to make the code do what’s required and then we refactor—which we’ll do in the next chapter.

我们再怎么强调“初次编写”的代码都不是完成的。它足以让我们理清思路,确保一切就绪,但它不太可能清晰地表达其意图。这会拖累生产力,因为在代码的整个生命周期中,它会被反复阅读。这就像没有打磨的木工活——最终会有人被刺伤。

We cannot emphasize strongly enough that “first-cut” code is not finished. It’s good enough to sort out our ideas and make sure we have everything in place, but it’s unlikely to express its intentions cleanly. That will make it a drag on productivity as it’s read repeatedly over the lifetime of the code. It’s like carpentry without sanding—eventually someone ends up with a nasty splinter.

第13章 狙击手出价

Chapter 13. The Sniper Makes a Bid

我们提取一个 AuctionSniper 类并梳理出它的依赖关系。我们将新类插入到应用程序的其余部分,使用拍卖的空实现,直到我们准备好开始发送命令。我们用一个 XMPPAuction 类关闭回到拍卖行的循环。我们继续从代码中挖掘新类型。

In which we extract an AuctionSniper class and tease out its dependencies. We plug our new class into the rest of the application, using an empty implementation of auction until we’re ready to start sending commands. We close the loop back to the auction house with an XMPPAuction class. We continue to carve new types out of the code.

AuctionSniper 简介

Introducing AuctionSniper

具有依赖关系的新类

A New Class, with Dependencies

我们的应用程序接受Price来自拍卖的事件,但还不能解释它们。我们需要在currentPrice()调用该方法时执行两个操作的代码:向拍卖发送更高的出价并更新用户界面中的状态。我们可以扩展Main,但该类看起来相当混乱——它同时做了太多事情。感觉现在是介绍我们称之为“拍卖狙击手”的东西的好时机,它是我们应用程序的核心组件,所以我们创建了一个AuctionSniper类。它的一些预期行为目前隐藏在中Main,一个好的开始是将其提取到我们的新类中——尽管我们稍后会看到,这需要一点努力。

Our application accepts Price events from the auction, but cannot interpret them yet. We need code that will perform two actions when the currentPrice() method is called: send a higher bid to the auction and update the status in the user interface. We could extend Main, but that class is looking rather messy—it’s already doing too many things at once. It feels like this is a good time to introduce what we should call an “Auction Sniper,” the component at the heart of our application, so we create an AuctionSniper class. Some of its intended behavior is currently buried in Main, and a good start would be to extract it into our new class—although, as we’ll see in a moment, it will take a little effort.

鉴于AuctionSniper应该响应Price事件,我们决定让它实现AuctionEventListener不是Main。问题是如何处理用户界面。如果我们考虑移动此方法:

Given that an AuctionSniper should respond to Price events, we decide to make it implement AuctionEventListener rather than Main. The question is what to do about the user interface. If we consider moving this method:

公共无效拍卖关闭(){

SwingUtilities.invokeLater(新Runnable(){

公共无效运行(){

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

public void auctionClosed() {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

AuctionSniper让 an了解用户界面的实现细节(例如 Swing 线程的使用)真的有意义吗?我们可能会再次面临违反“单一职责”原则的风险。an 肯定AuctionSniper应该关注竞标政策,并且只在条款中通知状态变化?

does it really make sense for an AuctionSniper to know about the implementation details of the user interface, such as the use of the Swing thread? We’d be at risk of breaking the “single responsibility” principle again. Surely an AuctionSniper ought to be concerned with bidding policy and only notify status changes in its terms?

我们的解决方案是通过引入一种新的关系来隔离AuctionSniper:它将通知SniperListener其状态的变化。界面和第一个单元测试如下所示:

Our solution is to insulate the AuctionSniper by introducing a new relationship: it will notify a SniperListener of changes in its status. The interface and the first unit test look like this:

公共接口 SniperListener 扩展了 EventListener {

void sniperLost();

}



@RunWith(JMock.class)

公共类 AuctionSniperTest {

私有最终 Mockery 上下文 = 新 Mockery();

私有最终 SniperListener sniperListener =

context.mock(SniperListener.class);

私有最终 AuctionSniper sniper = 新 AuctionSniper(sniperListener);



@Test 公共 void

reportsLostWhenAuctionCloses() {

context.checking(新 Expectations() {{

one(sniperListener).sniperLost(); }

});



sniper.auctionClosed();

}

}

public interface SniperListener extends EventListener {

void sniperLost();

}



@RunWith(JMock.class)

public class AuctionSniperTest {

private final Mockery context = new Mockery();

private final SniperListener sniperListener =

context.mock(SniperListener.class);

private final AuctionSniper sniper = new AuctionSniper(sniperListener);



@Test public void

reportsLostWhenAuctionCloses() {

context.checking(new Expectations() {{

one(sniperListener).sniperLost();

}});



sniper.auctionClosed();

}

}

Close这意味着,如果狙击手收到拍卖事件,它应该报告失败。

which says that Sniper should report that it has lost if it receives a Close event from the auction.

故障报告称:

The failure report says:

并非所有的期望都得到满足

期望:

!预期恰好 1 次,从未调用:SniperListener.sniperLost();

not all expectations were satisfied

expectations:

! expected exactly 1 time, never invoked: SniperListener.sniperLost();

我们可以通过一个简单的实现来实现:

which we can make pass with a simple implementation:

public class AuctionSniper 实现AuctionEventListener {

private final SniperListener sniperListener;



public AuctionSniper(SniperListener sniperListener) {

this.sniperListener = sniperListener;

}



public void auctionClosed() {

sniperListener.sniperLost();

}



public void currentPrice(int price, int increase) {

// TODO 自动生成的方法存根

}

}

public class AuctionSniper implements AuctionEventListener {

private final SniperListener sniperListener;



public AuctionSniper(SniperListener sniperListener) {

this.sniperListener = sniperListener;

}



public void auctionClosed() {

sniperListener.sniperLost();

}



public void currentPrice(int price, int increment) {

// TODO Auto-generated method stub

}

}

AuctionSniper最后,我们通过Main实施来改造新的SniperListener

Finally, we retrofit the new AuctionSniper by having Main implement SniperListener.

公共类 Main 实现SniperListener { [...]

私有 void joinAuction(XMPPConnection connection, String itemId)

抛出 XMPPException

{

disconnectWhenUICloses(connection);



聊天 chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

新 AuctionMessageTranslator(新 AuctionSniper(this) ));

this.notToBeGCd = chat;

聊天.sendMessage(JOIN_COMMAND_FORMAT);

}



公共 void sniperLost() {

SwingUtilities.invokeLater(new Runnable() {

公共 void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

}

public class Main implements SniperListener { [...]

private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

disconnectWhenUICloses(connection);



Chat chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new AuctionMessageTranslator(new AuctionSniper(this)));

this.notToBeGCd = chat;

chat.sendMessage(JOIN_COMMAND_FORMAT);

}



public void sniperLost() {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_LOST);

}

});

}

}

我们的端到端测试仍然通过,而损坏的测试仍然在同一个地方失败,所以我们没有让事情变得更糟。新的结构如图13.1所示。

Our working end-to-end test still passes and our broken one still fails at the same place, so we haven’t made things worse. The new structure looks like Figure 13.1.

图 13.1 插入 AuctionSniper

Figure 13.1 Plugging in the AuctionSniper

图像

专注,专注,专注

Focus, Focus, Focus

我们再次注意到类的复杂性,并利用这一点从我们最初的框架实现中梳理出一个新概念。现在我们有一个 Sniper 来响应来自翻译器的事件。您很快就会看到,这是一个更好的结构,可以表达代码的功能和进行单元测试。我们还认为该sniperLost()方法比以前的版本更清晰,auctionClosed()因为现在它的名称和它的功能更加匹配——即报告丢失的拍卖。

Once again, we’ve noticed complexity in a class and used that to tease out a new concept from our initial skeleton implementation. Now we have a Sniper to respond to events from the translator. As you’ll see shortly, this is a better structure for expressing what the code does and for unit testing. We also think that the sniperLost() method is clearer than its previous incarnation, auctionClosed(), since there’s now a closer match between its name and what it does—that is, reports a lost auction.

这不是浪费时间的摆弄、在代码上做文章而浪费时间吗?显然我们不这么认为,尤其是在项目早期整理想法时。有些团队在设计上投入了过多精力,但我们的经验是,大多数团队在澄清代码上花费的时间太少,为此付出了维护成本。正如我们多次展示的那样,“单一职责”原则是一种非常有效的分解复杂性的启发式方法,开发人员不应该羞于创建新类型。我们认为Main它做的还是太多了,但我们还不确定如何最好地分解它。我们决定继续努力,看看代码会带我们去哪里。

Isn’t this wasteful fiddling, gold-plating the code while time slips by? Obviously we don’t think so, especially when we’re sorting out our ideas this early in the project. There are teams that overdo their design effort, but our experience is that most teams spend too little time clarifying the code and pay for it in maintenance overhead. As we’ve shown a couple of times now, the “single responsibility” principle is a very effective heuristic for breaking up complexity, and developers shouldn’t be shy about creating new types. We think Main still does too much, but we’re not yet sure how best to break it up. We decide to push on and see where the code takes us.

发送投标

Sending a Bid

拍卖界面

An Auction Interface

下一步是让狙击手向拍卖发出出价,那么狙击手应该与谁交谈?扩展关系感觉不对,因为该关系是关于跟踪狙击手正在发生的事情,而不是做出外部承诺。在“对象对等刻板印象”(第52页)SniperListener中定义的术语中,是通知,而不是依赖关系SniperListener

The next step is to have the Sniper send a bid to the auction, so who should the Sniper talk to? Extending the SniperListener feels wrong because that relationship is about tracking what’s happening in the Sniper, not about making external commitments. In the terms defined in “Object Peer Stereotypes” (page 52), SniperListener is a notification, not a dependency.

经过常规讨论后,我们决定引入一个新的协作者,即AuctionAuctionSniperListener代表应用程序中的两个不同领域:Auction是关于金融交易的,它接受市场上物品的出价;SniperListener是关于应用程序的反馈,它报告狙击手当前状态的变化。Auction依赖项,因为狙击手没有它就无法运行,而SniperListener,正如我们上面讨论的那样, 不是。引入新界面使设计看起来像图 13.2

After the usual discussion, we decide to introduce a new collaborator, an Auction. Auction and SniperListener represent two different domains in the application: Auction is about financial transactions, it accepts bids for items in the market; and SniperListener is about feedback to the application, it reports changes to the current state of the Sniper. The Auction is a dependency, for a Sniper cannot function without one, whereas the SniperListener, as we discussed above, is not. Introducing the new interface makes the design look like Figure 13.2.

图 13.2 介绍 Auction

Figure 13.2 Introducing Auction

图像

拍卖狙击手出价

The AuctionSniper Bids

现在我们准备开始竞标了。第一步是实现对Price事件的响应,因此我们首先为 添加一个新的单元测试AuctionSniper。它表示狙击手在收到Price更新时会向拍卖发送增加的出价。它还会通知其侦听器它现在正在竞标,因此我们添加一个sniperBidding()方法。我们隐含地假设 知道Auction狙击手代表哪个竞标者,因此狙击手不必在出价时传递该信息。

Now we’re ready to start bidding. The first step is to implement the response to a Price event, so we start by adding a new unit test for the AuctionSniper. It says that the Sniper, when it receives a Price update, sends an incremented bid to the auction. It also notifies its listener that it’s now bidding, so we add a sniperBidding() method. We’re making an implicit assumption that the Auction knows which bidder the Sniper represents, so the Sniper does not have to pass in that information with the bid.

公共类 AuctionSniperTest {

私人最终拍卖拍卖 = context.mock(Auction.class);

私人最终拍卖Sniper 狙击手 =

新拍卖Sniper(拍卖,sniperListener);

[...]



@Test public void

bidsHigherAndReportsBiddingWhenNewPriceArrives(){

final int price = 1001;

final int increase = 25;

context.checking(new Expectations(){{

one(拍卖).bid(price + 增量);

atLeast(1).of(sniperListener)。sniperBidding();

}});



sniper.currentPrice(price,增量);

}

}

public class AuctionSniperTest {

private final Auction auction = context.mock(Auction.class);

private final AuctionSniper sniper =

new AuctionSniper(auction, sniperListener);

[...]



@Test public void

bidsHigherAndReportsBiddingWhenNewPriceArrives() {

final int price = 1001;

final int increment = 25;

context.checking(new Expectations() {{

one(auction).bid(price + increment);

atLeast(1).of(sniperListener).sniperBidding();

}});



sniper.currentPrice(price, increment);

}

}

失败报告为:

The failure report is:

并非所有预期都得到满足

预期:

!预期一次,从未调用:auction.bid(<1026>)

!预期至少 1 次,从未调用:sniperListener.sniperBidding()

在此之前发生了什么:什么都没有!

not all expectations were satisfied

expectations:

! expected once, never invoked: auction.bid(<1026>)

! expected at least 1 time, never invoked: sniperListener.sniperBidding()

what happened before this: nothing!

在编写测试时,我们意识到我们实际上并不关心狙击手是否多次通知侦听器它正在出价;这只是一个状态更新,因此我们使用一个atLeast(1)子句来表示侦听器的期望。另一方面,我们确实关心我们只发送一次出价,因此我们使用一个one()子句来表示它的期望。当然,在实践中,我们可能只会调用侦听器一次,但测试中条件的这种放松表达了我们对这两种关系的意图。测试表明,就调用方式而言,侦听器是一个比 更宽容的协作者Auction。我们还将该子句改进atLeast(1)为另一种测试方法。

When writing the test, we realized that we don’t actually care if the Sniper notifies the listener more than once that it’s bidding; it’s just a status update, so we use an atLeast(1) clause for the listener’s expectation. On the other hand, we do care that we send a bid exactly once, so we use a one() clause for its expectation. In practice, of course, we’ll probably only call the listener once, but this loosening of the conditions in the test expresses our intent about the two relationships. The test says that the listener is a more forgiving collaborator, in terms of how it’s called, than the Auction. We also retrofit the atLeast(1) clause to the other test method.

我们应该如何描述预期值?

How Should We Describe Expected Values?

图像

price我们通过添加和指定了预期出价increment。对于测试值是否应该只是具有“明显”值的文字,还是用它们所代表的计算来表达,存在不同的意见。写出计算可能会使测试更具可读性,但存在在测试中重新实现目标代码的风险,并且在某些情况下,计算会过于复杂而无法重现。在这里,我们认为计算非常简单,我们可以将其写入测试中。

We’ve specified the expected bid value by adding the price and increment. There are different opinions about whether test values should just be literals with “obvious” values, or expressed in terms of the calculation they represent. Writing out the calculation may make the test more readable but risks reimplementing the target code in the test, and in some cases the calculation will be too complicated to reproduce. Here, we decide that the calculation is so trivial that we can just write it into the test.

jMock 期望不需要按顺序匹配

jMock Expectations Don’t Need to Be Matched in Order

图像

这是我们的第一个具有多个期望的测试,因此我们要指出,声明期望的顺序不必与代码中调用方法的顺序一致。如果调用顺序确实很重要,则期望应该包含一个序列子句,如附录 A中所述。

This is our first test with more than one expectation, so we’ll point out that the order in which expectations are declared does not have to match the order in which the methods are called in the code. If the calling order does matter, the expectations should include a sequence clause, which is described in Appendix A.

使测试通过的实施很简单。

The implementation to make the test pass is simple.

公共接口拍卖 {

void bid(int amount);

}



公共类拍卖狙击手实现拍卖事件监听器 { [...]

私有最终SniperListener sniperListener;

私有最终拍卖拍卖;



公共拍卖狙击手(拍卖拍卖,SniperListener sniperListener) {

this.auction = 拍卖;

this.sniperListener = sniperListener;

}



公共void currentPrice(int price,int increase) {

拍卖.bid(price + increase);

sniperListener.sniperBidding();

}

}

public interface Auction {

void bid(int amount);

}



public class AuctionSniper implements AuctionEventListener { [...]

private final SniperListener sniperListener;

private final Auction auction;



public AuctionSniper(Auction auction, SniperListener sniperListener) {

this.auction = auction;

this.sniperListener = sniperListener;

}



public void currentPrice(int price, int increment) {

auction.bid(price + increment);

sniperListener.sniperBidding();

}

}

使用 AuctionSniper 成功竞标

Successfully Bidding with the AuctionSniper

现在我们必须将 newAuctionSniper重新折叠到应用程序中。简单的部分是显示竞标状态,(稍微)困难的部分是将出价发送回拍卖。我们的首要任务是让代码通过编译器。我们sniperBidding()在上实现新方法Main,为了避免代码编译时间过长,我们传递了AuctionSniper一个 null 实现Auction

Now we have to fold our new AuctionSniper back into the application. The easy part is displaying the bidding status, the (slightly) harder part is sending the bid back to the auction. Our first job is to get the code through the compiler. We implement the new sniperBidding() method on Main and, to avoid having code that doesn’t compile for too long, we pass the AuctionSniper a null implementation of Auction.

公共类 Main 实现 SniperListener { [...]

私有 void joinAuction(XMPPConnection connection, String itemId)

抛出 XMPPException

{

拍卖 nullAuction = 新拍卖() {

公共 void bid(int amount) {}

};

disconnectWhenUICloses(connection);



聊天 chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

新拍卖消息翻译器(新拍卖Sniper( nullAuction , this)));

this.notToBeGCd = chat;

聊天.sendMessage(JOIN_COMMAND_FORMAT);

}

公共 void sniperBidding() {

SwingUtilities.invokeLater(new Runnable() {

公共 void run() {

ui.showStatus(MainWindow.STATUS_BIDDING);

}

});

}

}

public class Main implements SniperListener { [...]

private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

Auction nullAuction = new Auction() {

public void bid(int amount) {}

};

disconnectWhenUICloses(connection);



Chat chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new AuctionMessageTranslator(new AuctionSniper(nullAuction, this)));

this.notToBeGCd = chat;

chat.sendMessage(JOIN_COMMAND_FORMAT);

}

public void sniperBidding() {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.showStatus(MainWindow.STATUS_BIDDING);

}

});

}

}

那么,实现中需要做什么呢Auction?它需要访问聊天功能,以便发送竞价消息。要创建聊天功能,我们需要一名翻译,翻译需要一名狙击手,狙击手需要拍卖。我们有一个依赖循环,需要打破它。

So, what goes in the Auction implementation? It needs access to the chat so it can send a bid message. To create the chat we need a translator, the translator needs a Sniper, and the Sniper needs an auction. We have a dependency loop which we need to break.

再次回顾我们的设计,有几个地方我们可以干预,但事实证明ChatManagerAPI 具有误导性。它不需要创建一个,即使方法暗示它需要。在我们的术语中,是一个通知;我们可以在创建时传入并MessageListener在稍后添加。ChatcreateChat()MessageListenernullChatMessageListener

Looking again at our design, there are a couple of places we could intervene, but it turns out that the ChatManager API is misleading. It does not require a MessageListener to create a Chat, even though the createChat() methods imply that it does. In our terms, the MessageListener is a notification; we can pass in null when we create the Chat and add a MessageListener later.

在 API 中表达意图

Expressing Intent in API

图像

我们之所以能够发现可以null作为传递MessageListener,只是因为我们有 Smack 库的源代码。从 API 中看不出这一点,因为作者可能想要强制执行正确的行为,而且不清楚为什么有人会想要Chat没有侦听器的 。另一种方法是提供不带侦听器的等效创建方法,但这会导致 API 膨胀。这里没有明显的最佳方法,但需要注意的是,在分发版中包含结构良好的源代码会使库更易于使用。

We were only able to discover that we could pass null as a MessageListener because we have the source code to the Smack library. This isn’t clear from the API because, presumably, the authors wanted to enforce the right behavior and it’s not clear why anyone would want a Chat without a listener. An alternative would have been to provide equivalent creation methods that don’t take a listener, but that would lead to API bloat. There isn’t an obvious best approach here, except to note that including well-structured source code with the distribution makes libraries much easier to work with.

现在我们可以重组我们的连接代码并使用Chat来发回出价。

Now we can restructure our connection code and use the Chat to send back a bid.

公共类 Main 实现 SniperListener { [...]

私有 void joinAuction(XMPPConnection connection,String itemId)

抛出 XMPPException

{

disconnectWhenUICloses(connection);



最终聊天 chat =

connection.getChatManager()。createChat(auctionId(itemId,connection),null);

this.notToBeGCd = chat;



拍卖 auction = 新 Auction() {

公共 void bid(int amount){

尝试 {

chat.sendMessage(String.format(BID_COMMAND_FORMAT,amount));

} catch(XMPPException e){

e.printStackTrace();

}

}

};

chat.addMessageListener(

new AuctionMessageTranslator(new AuctionSniper(auction,this)));

chat.sendMessage(JOIN_COMMAND_FORMAT);

}

}

public class Main implements SniperListener { [...]

private void joinAuction(XMPPConnection connection, String itemId)

throws XMPPException

{

disconnectWhenUICloses(connection);



final Chat chat =

connection.getChatManager().createChat(auctionId(itemId, connection), null);

this.notToBeGCd = chat;



Auction auction = new Auction() {

public void bid(int amount) {

try {

chat.sendMessage(String.format(BID_COMMAND_FORMAT, amount));

} catch (XMPPException e) {

e.printStackTrace();

}

}

};

chat.addMessageListener(

new AuctionMessageTranslator(new AuctionSniper(auction, this)));

chat.sendMessage(JOIN_COMMAND_FORMAT);

}

}

空实现

Null Implementation

图像

实现类似于空对象 [Woolf98]:两者都是通过不执行任何操作来响应协议的实现 — — 但意图不同。空对象通常是众多实现之一,引入它是为了降低调用协议的代码的复杂性。我们将空实现定义为临时的空实现,它允许程序员通过推迟工作来取得进展。在我们发货之前,它将被真正的实现取代。

A null implementation is similar to a null object [Woolf98]: both are implementations that respond to a protocol by not doing anything—but the intention is different. A null object is usually one implementation amongst many, introduced to reduce complexity in the code that calls the protocol. We define a null implementation as a temporary empty implementation, that allows the programmer to make progress by deferring effort. It will be replaced be a real implementation before we ship.

端到端测试通过

The End-to-End Tests Pass

现在端到端测试通过了:狙击手可以在没有出价的情况下失败,也可以在出价后失败。我们可以从待办事项清单中划掉另一项,但这包括捕获并打印XMPPException。通常,我们认为这是一种非常糟糕的做法,但我们希望看到测试通过并在代码中获得一些结构——我们知道如果发送消息时出现问题,端到端测试无论如何都会失败。为了确保我们不会忘记,我们添加了另一个待办事项来寻找更好的解决方案,如图 13.3 所示

Now the end-to-end tests pass: the Sniper can lose without making a bid, and lose after making a bid. We can cross off another item on the to-do list, but that includes just catching and printing the XMPPException. Normally, we regard this as a very bad practice but we wanted to see the tests pass and get some structure into the code—and we know that the end-to-end tests will fail anyway if there’s a problem sending a message. To make sure we don’t forget, we add another to-do item to find a better solution, Figure 13.3.

图 13.3 向前迈一步

Figure 13.3 One step forward

图像

整理实施

Tidying Up the Implementation

提取 XMPPAuction

Extracting XMPPAuction

我们的端到端测试通过了,但我们还没有完成,因为我们的新实现感觉很混乱。我们注意到中的活动joinAuction()跨越多个领域:管理聊天、发送出价、创建狙击手等等。我们需要清理一下。首先,我们注意到我们从两个不同的级别发送拍卖命令,即在顶部和内部Auction。向拍卖发送命令听起来像是我们的对象应该做的事情Auction,所以把它们打包在一起是有意义的。我们向接口添加一个新方法,扩展我们的匿名实现,然后将其提取到一个(临时)嵌套的类中——我们需要一个名字。的这个实现的显着特点Auction是它基于消息传递基础结构,因此我们将我们的新类称为XMPPAuction

Our end-to-end test passes, but we haven’t finished because our new implementation feels messy. We notice that the activity in joinAuction() crosses multiple domains: managing chats, sending bids, creating snipers, and so on. We need to clean up. To start, we notice that we’re sending auction commands from two different levels, at the top and from within the Auction. Sending commands to an auction sounds like the sort of thing that our Auction object should do, so it makes sense to package that up together. We add a new method to the interface, extend our anonymous implementation, and then extract it to a (temporarily) nested class—for which we need a name. The distinguishing feature of this implementation of Auction is that it’s based on the messaging infrastructure, so we call our new class XMPPAuction.

公共类 Main 实现 SniperListener { [...]

私有 void joinAuction(XMPPConnection connection, String itemId) {

disconnectWhenUICloses(connection);



最终聊天 chat =

connection.getChatManager().createChat(auctionId(itemId, connection),

null);

this.notToBeGCd = chat;



拍卖 auction =新 XMPPAuction(聊天);

chat.addMessageListener(

新 AuctionMessageTranslator(新 AuctionSniper(auction, this)));

拍卖.join();

}



公共静态类XMPPAuction 实现拍卖 {

私有最终聊天聊天;



公共 XMPPAuction(聊天聊天) {

this.chat = chat;

}



公共 void bid(int amount) {

sendMessage(format(BID_COMMAND_FORMAT, amount));

}



公共 void join() {

sendMessage(JOIN_COMMAND_FORMAT);

}



私有 void sendMessage(最终字符串消息) {

尝试 {

聊天.sendMessage(消息);

} 捕获(XMPPException e){

e.printStackTrace();

}

}

}

}

public class Main implements SniperListener { [...]

private void joinAuction(XMPPConnection connection, String itemId) {

disconnectWhenUICloses(connection);



final Chat chat =

connection.getChatManager().createChat(auctionId(itemId, connection),

null);

this.notToBeGCd = chat;



Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(new AuctionSniper(auction, this)));

auction.join();

}



public static class XMPPAuction implements Auction {

private final Chat chat;



public XMPPAuction(Chat chat) {

this.chat = chat;

}



public void bid(int amount) {

sendMessage(format(BID_COMMAND_FORMAT, amount));

}



public void join() {

sendMessage(JOIN_COMMAND_FORMAT);

}



private void sendMessage(final String message) {

try {

chat.sendMessage(message);

} catch (XMPPException e) {

e.printStackTrace();

}

}

}

}

我们开始看到更清晰的领域模型。这一行auction.join()比之前向聊天发送字符串的详细实现更清楚地表达了我们的意图。新设计如图 13.4所示,我们将其提升XMPPAuction为顶级类。

We’re starting to see a clearer model of the domain. The line auction.join() expresses our intent more clearly than the previous detailed implementation of sending a string to a chat. The new design looks like Figure 13.4 and we promote XMPPAuction to be a top-level class.

图 13.4 使用 XMPPAuction

Figure 13.4 Closing the loop with an XMPPAuction

图像

我们仍然认为joinAuction()还不够清楚,我们想从中提取与 XMPP 相关的细节Main,但我们还没有准备好这样做。还有一点要记住。

We still think joinAuction() is unclear, and we’d like to pull the XMPP-related detail out of Main, but we’re not ready to do that yet. Another point to keep in mind.

提取用户界面

Extracting the User Interface

中的其他活动Main是实现用户界面并显示当前状态以响应来自狙击手的事件。我们对Main实现并不满意SniperListener;同样,这感觉像是混合了不同的职责(启动应用程序和响应事件)。我们决定将SniperListener行为提取到嵌套的辅助类中,我们能找到的最佳名称是SniperStateDisplayer。这个新类是我们两个领域之间的桥梁:它将狙击手事件转换为 Swing 可以显示的表示形式,其中包括处理 Swing 线程。我们将新类的一个实例插入到中AuctionSniper

The other activity in Main is implementing the user interface and showing the current state in response to events from the Sniper. We’re not really happy that Main implements SniperListener; again, it feels like mixing different responsibilities (starting the application and responding to events). We decide to extract the SniperListener behavior into a nested helper class, for which the best name we can find is SniperStateDisplayer. This new class is our bridge between two domains: it translates Sniper events into a representation that Swing can display, which includes dealing with Swing threading. We plug an instance of the new class into the AuctionSniper.

公共类 Main { // 不实现 SniperListener

私有 MainWindow ui ;



私有 void joinAuction(XMPPConnection connection, String itemId) {

disconnectWhenUICloses(connection);

最终聊天 chat =

connection.getChatManager().createChat(auctionId(itemId, connection), null);

this.notToBeGCd = chat;



拍卖 auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(auction, new SniperStateDisplayer() )));

auction.join();

}



[...]

公共类 SniperStateDisplayer 实现 SniperListener {

public void sniperBidding() {

showStatus(MainWindow.STATUS_BIDDING);

}



public void sniperLost() {

showStatus(MainWindow.STATUS_LOST);

}



public void sniperWinning() {

showStatus(MainWindow.STATUS_WINNING);

}



private void showStatus(final String status) {

SwingUtilities.invokeLater(new Runnable() {

public void run() { ui .showStatus(status); }

});

}

}

}

public class Main { // doesn't implement SniperListener

private MainWindow ui;



private void joinAuction(XMPPConnection connection, String itemId) {

disconnectWhenUICloses(connection);

final Chat chat =

connection.getChatManager().createChat(auctionId(itemId, connection), null);

this.notToBeGCd = chat;



Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(auction, new SniperStateDisplayer())));

auction.join();

}



[...]

public class SniperStateDisplayer implements SniperListener {

public void sniperBidding() {

showStatus(MainWindow.STATUS_BIDDING);

}



public void sniperLost() {

showStatus(MainWindow.STATUS_LOST);

}



public void sniperWinning() {

showStatus(MainWindow.STATUS_WINNING);

}



private void showStatus(final String status) {

SwingUtilities.invokeLater(new Runnable() {

public void run() { ui.showStatus(status); }

});

}

}

}

图 13.5显示了我们如何将Main其精简到不再参与正在运行的应用程序(为了清楚起见,我们省略了WindowAdapter关闭连接的)。它有一项工作,即创建各种组件并将它们相互引入。我们将其标记MainWindow为外部,即使它是我们的组件之一,也表示 Swing 框架。

Figure 13.5 shows how we’ve reduced Main so much that it no longer participates in the running application (for clarity, we’ve left out the WindowAdapter that closes the connection). It has one job which is to create the various components and introduce them to each other. We’ve marked MainWindow as external, even though it’s one of ours, to represent the Swing framework.

图 13.5 提取 SniperStateDisplayer

Figure 13.5 Extracting SniperStateDisplayer

图像

整理翻译器

Tidying Up the Translator

最后,我们履行对自己的承诺,回到AuctionMessageTranslator。我们开始尝试通过添加常量和静态导入来减少噪音,并使用一些辅助方法来减少重复。然后我们意识到大部分代码都是关于操作名称/值对的映射,而且相当程序化。我们可以通过提取内部类 来AuctionEvent封装消息内容的解包,从而做得更好。我们有信心可以安全地重构该类,因为它受到单元测试的保护。

Finally, we fulfill our promise to ourselves and return to the AuctionMessageTranslator. We start trying to reduce the noise by adding constants and static imports, with some helper methods to reduce duplication. Then we realize that much of the code is about manipulating the map of name/value pairs and is rather procedural. We can do a better job by extracting an inner class, AuctionEvent, to encapsulate the unpacking of the message contents. We have confidence that we can refactor the class safely because it’s protected by its unit tests.

公共类 AuctionMessageTranslator 实现 MessageListener {

私有最终 AuctionEventListener 侦听器;



公共 AuctionMessageTranslator(AuctionEventListener 侦听器) {

this.listener = 侦听器;

}

公共 void processMessage(Chat chat,Message message) {

AuctionEvent 事件 = AuctionEvent.from(message.getBody());



String eventType = event.type()

如果(“CLOSE”.equals(eventType)){

listener.auctionClosed();

} 如果(“PRICE”.equals(eventType)){

listener.currentPrice(event.currentPrice(, event.increment());

}

}

私有静态类 AuctionEvent {

私有最终 Map<String, String> fields = new HashMap<String, String>();

公共 String type() { return get(“Event”); }

公共 int currentPrice() { return getInt(“CurrentPrice”); }

public int increase() { return getInt("增量"); }



private int getInt(String fieldName) {

return Integer.parseInt(get(fieldName));

}

private String get(String fieldName) { return fields.get(fieldName); }



private void addField(String field) {

String[] pair = field.split(":");

fields.put(pair[0].trim(), pair[1].trim());

}

static AuctionEvent from(String messageBody) {

AuctionEvent event = new AuctionEvent();

for (String field : fieldsIn(messageBody)) {

event.addField(field);

}

return event;

}

static String[] fieldsIn(String messageBody) {

return messageBody.split(";");

}

}

}

public class AuctionMessageTranslator implements MessageListener {

private final AuctionEventListener listener;



public AuctionMessageTranslator(AuctionEventListener listener) {

this.listener = listener;

}

public void processMessage(Chat chat, Message message) {

AuctionEvent event = AuctionEvent.from(message.getBody());



String eventType = event.type();

if ("CLOSE".equals(eventType)) {

listener.auctionClosed();

} if ("PRICE".equals(eventType)) {

listener.currentPrice(event.currentPrice(), event.increment());

}

}

private static class AuctionEvent {

private final Map<String, String> fields = new HashMap<String, String>();

public String type() { return get("Event"); }

public int currentPrice() { return getInt("CurrentPrice"); }

public int increment() { return getInt("Increment"); }



private int getInt(String fieldName) {

return Integer.parseInt(get(fieldName));

}

private String get(String fieldName) { return fields.get(fieldName); }



private void addField(String field) {

String[] pair = field.split(":");

fields.put(pair[0].trim(), pair[1].trim());

}

static AuctionEvent from(String messageBody) {

AuctionEvent event = new AuctionEvent();

for (String field : fieldsIn(messageBody)) {

event.addField(field);

}

return event;

}

static String[] fieldsIn(String messageBody) {

return messageBody.split(";");

}

}

}

这是我们在“值类型”(第59页)中描述的“突破”示例。它可能并不明显,但AuctionEvent它是一个值:它是不可变的,并且具有相同内容的两个实例之间没有有趣的差异。此重构将关注点分离AuctionMessageTranslator:顶层处理事件和侦听器,内部对象处理解析字符串。

This is an example of “breaking out” that we described in “Value Types” (page 59). It may not be obvious, but AuctionEvent is a value: it’s immutable and there are no interesting differences between two instances with the same contents. This refactoring separates the concerns within AuctionMessageTranslator: the top level deals with events and listeners, and the inner object deals with parsing strings.

封装集合

Encapsulate Collections

图像

尽管 Java 泛型避免了强制转换对象的需要,但我们养成了在自己的类中打包常见类型(如集合)的习惯。我们试图使用我们正在处理的问题的语言,而不是 Java 构造的语言。在我们的两个版本中processMessage(),第一个版本在查找和解析值方面有很多偶然的干扰。第二个版本是根据拍卖活动编写的,因此领域和代码之间的概念差距较小。

We’ve developed a habit of packaging up common types, such as collections, in our own classes, even though Java generics avoid the need to cast objects. We’re trying to use the language of the problem we’re working on, rather than the language of Java constructs. In our two versions of processMessage(), the first has lots of incidental noise about looking up and parsing values. The second is written in terms of auction events, so there’s less of a conceptual gap between the domain and the code.

我们的经验法则是尽量限制使用泛型(尖括号中的类型)传递类型。特别是当应用于集合时,我们认为这是一种重复。这暗示着有一个领域概念应该被提取到类型中。

Our rule of thumb is that we try to limit passing around types with generics (the types enclosed in angle brackets). Particularly when applied to collections, we view it as a form of duplication. It’s a hint that there’s a domain concept that should be extracted into a type.

推迟决策

Defer Decisions

我们已经使用过几次一种技巧,即引入方法(甚至是类型)的 null 实现来帮助我们完成下一步。这有助于我们专注于眼前的任务,而不会被拖入思考下一个重要的功能块。Auction例如,null 允许我们插入我们在单元测试中发现的新关系,而不会陷入消息传递问题。这反过来意味着我们可以停下来思考对象之间的依赖关系,而不必担心编译中断。

There’s a technique we’ve used a couple of times now, which is to introduce a null implementation of a method (or even a type) to get us through the next step. This helps us focus on the immediate task without getting dragged into thinking about the next significant chunk of functionality. The null Auction, for example, allowed us to plug in a new relationship we’d discovered in a unit test without getting pulled into messaging issues. That, in turn, meant we could stop and think about the dependencies between our objects without the pressure of having a broken compilation.

继续编译代码

Keep the Code Compiling

图像

我们尝试通过保持更改的增量来尽量减少代码无法编译的时间。当编译失败时,我们不能完全确定更改的边界在哪里,因为编译器无法告诉我们。这反过来意味着我们无法签入源代码存储库,而我们喜欢经常这样做。我们打开的代码越多,我们需要记住的东西就越多,具有讽刺意味的是,这通常意味着我们的进度会更慢。测试驱动开发的伟大发现之一就是我们的开发步骤可以多么细粒度。

We try to minimize the time when we have code that does not compile by keeping changes incremental. When we have compilation failures, we can’t be quite sure where the boundaries of our changes are, since the compiler can’t tell us. This, in turn, means that we can’t check in to our source repository, which we like to do often. The more code we have open, the more we have to keep in our heads which, ironically, usually means we move more slowly. One of the great discoveries of test-driven development is just how fine-grained our development steps can be.

新兴设计

Emergent Design

我们希望从本章中清楚地看到,我们如何从一个看似毫无希望的开始发展一个设计。我们或多或少地交替添加功能和反思(并清理)由此产生的代码。清理阶段至关重要,因为没有它,我们最终会陷入无法维护的混乱之中。如果我们还不清楚该做什么,我们准备推迟重构代码,并相信我们会在准备好时花时间。与此同时,我们尽可能保持代码整洁,以小增量移动并使用诸如空实现之类的技术来最大限度地减少代码损坏的时间。

What we hope is becoming clear from this chapter is how we’re growing a design from what looks like an unpromising start. We alternate, more or less, between adding features and reflecting on—and cleaning up—the code that results. The cleaning up stage is essential, since without it we would end up with an unmaintainable mess. We’re prepared to defer refactoring code if we’re not yet clear what to do, confident that we will take the time when we’re ready. In the meantime, we keep our code as clean as possible, moving in small increments and using techniques such as null implementation to minimize the time when it’s broken.

图 13.5显示了我们在核心实现周围构建了一个层,以“保护”它免受外部依赖的影响。我们认为这只是一种很好的做法,但有趣的是,我们正在逐步实现这一目标,通过寻找类中可以搭配或不搭配的特性。当然,我们会受到处理类似代码库的经验的影响,但我们会努力遵循代码告诉我们的内容,而不是强加我们的先入之见。有时,当我们这样做时,我们会发现领域会把我们带向最令人惊讶的方向。

Figure 13.5 shows that we’re building up a layer around our core implementation that “protects” it from its external dependencies. We think this is just good practice, but what’s interesting is that we’re getting there incrementally, by looking for features in classes that either go together or don’t. Of course we’re influenced by our experience of working on similar codebases, but we’re trying hard to follow what the code is telling us instead of imposing our preconceptions. Sometimes, when we do this, we find that the domain takes us in the most surprising directions.

第14章 狙击手赢得拍卖

Chapter 14. The Sniper Wins the Auction

我们为 Sniper 添加了另一项功能,并让它赢得拍卖。我们向 Sniper 引入了状态概念,并通过监听其回调来测试它。我们发现,即使这么早,我们的一项重构也已取得成效。

In which we add another feature to our Sniper and let it win an auction. We introduce the concept of state to the Sniper which we test by listening to its callbacks. We find that even this early, one of our refactorings has paid off.

一、测试失败

First, a Failing Test

我们有一个狙击手,它可以通过出价更高来响应价格变化,但它还不知道什么时候成功。我们待办事项清单上的下一个功能是赢得拍卖。这涉及额外的状态转换,如图14.1所示:

We have a Sniper that can respond to price changes by bidding more, but it doesn’t yet know when it’s successful. Our next feature on the to-do list is to win an auction. This involves an extra state transition, as you can see in Figure 14.1:

图 14.1 狙击手出价然后获胜

Figure 14.1 A sniper bids, then wins

图像

为了表示这一点,我们添加了基于的端到端测试,并得出了sniperMakesAHigherBidButLoses()不同的结论sniperWinsAnAuctionByBiddingHigher()——。以下是测试结果,其中突出显示了新功能:

To represent this, we add an end-to-end test based on sniperMakesAHigherBidButLoses() with a different conclusion—sniperWinsAnAuctionByBiddingHigher(). Here’s the test, with the new features highlighted:

公共类 AuctionSniperEndToEndTest { [...]

@Test public void

sniperWinsAnAuctionByBiddingHigher() 抛出异常 {

拍卖.startSellingItem();



应用程序.startBiddingIn(拍卖);

拍卖.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



拍卖.reportPrice(1000, 98, "其他竞标者");

应用程序.hasShownSniperIsBidding();



拍卖.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



拍卖.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);应用

程序.hasShownSniperIsWinning();



拍卖.announceClosed();

应用程序.showsSniperHasWonAuction();

}

}

public class AuctionSniperEndToEndTest { [...]

@Test public void

sniperWinsAnAuctionByBiddingHigher() throws Exception {

auction.startSellingItem();



application.startBiddingIn(auction);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1000, 98, "other bidder");

application.hasShownSniperIsBidding();



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);

application.hasShownSniperIsWinning();



auction.announceClosed();

application.showsSniperHasWonAuction();

}

}

在我们的测试基础架构中,我们添加了两种方法来检查用户界面是否向其显示两种新状态ApplicationRunner

In our test infrastructure we add the two methods to check that the user interface shows the two new states to the ApplicationRunner.

这将生成一条新的失败消息:

This generates a new failure message:

java.lang.AssertionError:尝试 在所有顶层窗口中

查找...恰好 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)中的

1 个 JLabel(名称为“sniper status”)并检查其标签文本是否为“Winning”但... 所有顶层窗口 均包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)且 包含 1 个 JLabel(名称为“sniper status”)标签文本为“Bidding”















java.lang.AssertionError:

Tried to look for...

exactly 1 JLabel (with name "sniper status")

in exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

and check that its label text is "Winning"

but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JLabel (with name "sniper status")

label text was "Bidding"

现在我们知道要去哪里,我们可以实现该功能了。

Now we know where we’re going, we can implement the feature.

谁了解竞标者?

Who Knows about Bidders?

如果竞拍者是拍卖接受的最后一个价格的竞拍者,则应用程序知道狙击手获胜。我们必须决定把这个逻辑放在哪里。再看第134页的图 13.5,一种选择是翻译器可以将竞拍者传递给狙击手,让狙击手决定。这意味着狙击手必须知道拍卖如何识别竞拍者,并有可能获取我们一直小心保密的 XMPP 详细信息。要决定它是否获胜,狙击手在价格到达时唯一需要知道的是,这个价格是出的吗?这是一个选择,而不是标识符,因此我们将用PriceSource包含在内的枚举来表示它AuctionEventListener1

The application knows that the Sniper is winning if it’s the bidder for the last price that the auction accepted. We have to decide where to put that logic. Looking again at Figure 13.5 on page 134, one choice would be that the translator could pass the bidder through to the Sniper and let the Sniper decide. That would mean that the Sniper would have to know something about how bidders are identified by the auction, with a risk of pulling in XMPP details that we’ve been careful to keep separate. To decide whether it’s winning, the only thing the Sniper needs to know when a price arrives is, did this price come from me? This is a choice, not an identifier, so we’ll represent it with an enumeration PriceSource which we include in AuctionEventListener.1

1.我们知道有些开发人员对嵌套类型有过敏反应。在 Java 中,我们将它们用作细粒度作用域的一种形式。在这种情况下,PriceSource总是与 一起使用AuctionEventListener,因此将两者绑定在一起是有意义的。

1. Some developers we know have an allergic reaction to nested types. In Java, we use them as a form of fine-grained scoping. In this case, PriceSource is always used together with AuctionEventListener, so it makes sense to bind the two together.

顺便说一句,是值类型PriceSource的一个例子。我们希望代码能够描述狙击的范围,而不是每次读取时都必须解释的布尔值;在“值类型” (第59页) 中有更多讨论。

Incidentally, PriceSource is an example of a value type. We want code that describes the domain of Sniping—not, say, a boolean which we would have to interpret every time we read it; there’s more discussion in “Value Types” (page 59).

公共接口AuctionEventListener扩展了EventListener {

枚举PriceSource {

FromSniper,FromOtherBidder;

};

[...]

public interface AuctionEventListener extends EventListener {

enum PriceSource {

FromSniper, FromOtherBidder;

};

[...]

我们认为,确定这是否是我们的价格是翻译器职责的一部分。我们扩展了currentPrice()一个新参数并更改了翻译器的单元测试;请注意,我们更改了现有测试的名称以包含额外的功能。我们还借此机会将狙击手标识符传递给翻译器SNIPER_ID。这将翻译器的设置与第二个测试中的输入消息联系起来。

We take the view that determining whether this is our price or not is part of the translator’s role. We extend currentPrice() with a new parameter and change the translator’s unit tests; note that we change the name of the existing test to include the extra feature. We also take the opportunity to pass the Sniper identifier to the translator in SNIPER_ID. This ties the setup of the translator to the input message in the second test.

公共类AuctionMessageTranslatorTest { [...]

私有最终AuctionMessageTranslator translator =

new AuctionMessageTranslator(SNIPER_ID,监听器);



@Test public void

notifiesBidDetailsWhenCurrentPriceMessageReceived FromOtherBidder(){

context.checking(new Expectations(){{

exact(1).of(listener).currentPrice(192,7,PriceSource.FromOtherBidder);

}});

Message message = new Message();

message.setBody(

“SOLVersion:1.1;事件:PRICE; CurrentPrice:192;增量:7;投标人:其他人;”

);

translator.processMessage(UNUSED_CHAT,消息);

}



@Test public void

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromSniper() {

context.checking(new Expectations() {{

exact(1).of(listener).currentPrice(234, 5, PriceSource.FromSniper );

}});

Message message = new Message();

message.setBody(

"SOLVersion: 1.1; 事件: PRICE; CurrentPrice: 234; 增量: 5; 投标人: "

+ SNIPER_ID + ";");

translator.processMessage(UNUSED_CHAT, message);

}

}

public class AuctionMessageTranslatorTest { [...]

private final AuctionMessageTranslator translator =

new AuctionMessageTranslator(SNIPER_ID, listener);



@Test public void

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromOtherBidder() {

context.checking(new Expectations() {{

exactly(1).of(listener).currentPrice(192, 7, PriceSource.FromOtherBidder);

}});

Message message = new Message();

message.setBody(

"SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7; Bidder: Someone else;"

);

translator.processMessage(UNUSED_CHAT, message);

}



@Test public void

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromSniper() {

context.checking(new Expectations() {{

exactly(1).of(listener).currentPrice(234, 5, PriceSource.FromSniper);

}});

Message message = new Message();

message.setBody(

"SOLVersion: 1.1; Event: PRICE; CurrentPrice: 234; Increment: 5; Bidder: "

+ SNIPER_ID + ";");

translator.processMessage(UNUSED_CHAT, message);

}

}

新的测试失败:

The new test fails:

意外调用:

auctionEventListener.currentPrice(<192>, <7>, <FromOtherBidder>)

期望:

!预期一次,从未调用:

auctionEventListener.currentPrice(<192>, <7>, <FromSniper>)

参数 0 匹配:<192>

参数 1 匹配:<7>

参数 2 不匹配:<FromSniper>,因为是 <FromOtherBidder>

在此之前发生了什么:什么都没有!

unexpected invocation:

auctionEventListener.currentPrice(<192>, <7>, <FromOtherBidder>)

expectations:

! expected once, never invoked:

auctionEventListener.currentPrice(<192>, <7>, <FromSniper>)

parameter 0 matched: <192>

parameter 1 matched: <7>

parameter 2 did not match: <FromSniper>, because was <FromOtherBidder>

what happened before this: nothing!

解决方法是将狙击手标识符与事件消息中的投标人进行比较。

The fix is to compare the Sniper identifier to the bidder from the event message.

公共类 AuctionMessageTranslator 实现 MessageListener { [...]

私有最终字符串 sniperId;



公共无效 processMessage(聊天聊天,消息消息) {

[...]

} else if (EVENT_TYPE_PRICE.equals(类型)) {

listener.currentPrice(event.currentPrice(),

event.increment(),

event.isFrom(sniperId));

}

}



公共静态类 AuctionEvent { [...]

公共 PriceSource isFrom(字符串 sniperId) {

return sniperId.equals(bidder()) ? FromSniper : FromOtherBidder;

}

私有字符串 bidder() { return get("Bidder"); }

}

}

public class AuctionMessageTranslator implements MessageListener { [...]

private final String sniperId;



public void processMessage(Chat chat, Message message) {

[...]

} else if (EVENT_TYPE_PRICE.equals(type)) {

listener.currentPrice(event.currentPrice(),

event.increment(),

event.isFrom(sniperId));

}

}



public static class AuctionEvent { [...]

public PriceSource isFrom(String sniperId) {

return sniperId.equals(bidder()) ? FromSniper : FromOtherBidder;

}

private String bidder() { return get("Bidder"); }

}

}

我们在“整理翻译器”(第 135页)中所做的工作,将翻译器中的不同职责分开,在这里得到了回报。我们所要做的就是添加几个额外的方法来AuctionEvent获得一个非常易读的解决方案。

The work we did in “Tidying Up the Translator” (page 135) to separate the different responsibilities within the translator has paid off here. All we had to do was add a couple of extra methods to AuctionEvent to get a very readable solution.

最后,为了让所有代码通过编译器,我们修复joinAuction()Main,以便为翻译器传入新的构造函数参数。我们可以从 中获得一个结构正确的标识符connection

Finally, to get all the code through the compiler, we fix joinAuction() in Main to pass in the new constructor parameter for the translator. We can get a correctly structured identifier from connection.

私有 void joinAuction(XMPPConnection 连接,String itemId){

[...]

拍卖拍卖 = 新 XMPPAuction(聊天);

聊天.addMessageListener(

新 AuctionMessageTranslator(

连接.getUser(,

新 AuctionSniper(拍卖,新 SniperStateDisplayer())));

拍卖.join();

}

private void joinAuction(XMPPConnection connection, String itemId) {

[...]

Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(auction, new SniperStateDisplayer())));

auction.join();

}

狙击手还有话要说

The Sniper Has More to Say

我们的端到端测试失败告诉我们,我们应该让用户界面显示狙击手获胜的时间。我们的下一个实施步骤是修复AuctionSniper解释isFromSniper我们刚刚添加的参数。我们再次从单元测试开始。

Our immediate end-to-end test failure tells us that we should make the user interface show when the Sniper is winning. Our next implementation step is to follow through by fixing the AuctionSniper to interpret the isFromSniper parameter we’ve just added. Once again we start with a unit test.

公共类AuctionSniperTest { [...]

@Test public void

reportsIsWinningWhenCurrentPriceComesFromSniper() {

context.checking(new Expectations() {{

atLeast(1).of(sniperListener). sniperWinning ();

}});



sniper.currentPrice(123, 45, PriceSource.FromSniper );

}

}

public class AuctionSniperTest { [...]

@Test public void

reportsIsWinningWhenCurrentPriceComesFromSniper() {

context.checking(new Expectations() {{

atLeast(1).of(sniperListener).sniperWinning();

}});



sniper.currentPrice(123, 45, PriceSource.FromSniper);

}

}

为了通过编译器,我们添加了新sniperWinning()方法SniperListener,这反过来意味着我们为添加了一个空的实现SniperStateDisplayer

To get through the compiler, we add the new sniperWinning() method to SniperListener which, in turn, means that we add an empty implementation to SniperStateDisplayer.

测试失败:

The test fails:

意外调用:auction.bid(<168>)

期望:

!预计至少发生 1 次,从未调用:sniperListener.sniperWinning()

在此之前发生了什么:什么都没有!

unexpected invocation: auction.bid(<168>)

expectations:

! expected at least 1 time, never invoked: sniperListener.sniperWinning()

what happened before this: nothing!

这个失败是一个很好的例子,它捕获了一个我们意想不到的方法。我们没有对 设定任何期望auction,因此对其任何方法的调用都将使测试失败。如果将此测试与bidsHigherAndReportsBiddingWhenNewPriceArrives()The AuctionSniper Bids ”(第 126页)中的测试进行比较,您还会发现我们删除了priceincrement变量,只输入了数字。这是因为,在这个测试中,不需要进行任何计算,所以我们不需要在期望中引用它们。它们只是让我们了解有趣行为的细节。

This failure is a nice example of trapping a method that we didn’t expect. We set no expectations on the auction, so calls to any of its methods will fail the test. If you compare this test to bidsHigherAndReportsBiddingWhenNewPriceArrives() in “The AuctionSniper Bids” (page 126) you’ll also see that we drop the price and increment variables and just feed in numbers. That’s because, in this test, there’s no calculation to do, so we don’t need to reference them in an expectation. They’re just details to get us to the interesting behavior.

修复方法很简单:

The fix is straightforward:

公共类 AuctionSniper 实现 AuctionEventListener { [...]

公共 void currentPrice(int price, int increase, PriceSource priceSource) {

switch (priceSource) {

case FromSniper:

sniperListener.sniperWinning();

break;

case FromOtherBidder:

auction.bid(price + increase);

sniperListener.sniperBidding();

break;

}

}

}

public class AuctionSniper implements AuctionEventListener { [...]

public void currentPrice(int price, int increment, PriceSource priceSource) {

switch (priceSource) {

case FromSniper:

sniperListener.sniperWinning();

break;

case FromOtherBidder:

auction.bid(price + increment);

sniperListener.sniperBidding();

break;

}

}

}

再次运行端到端测试表明我们已经修复了启动本章的故障(显示Bidding而不是Winning)。现在我们必须让狙击手获胜:

Running the end-to-end tests again shows that we’ve fixed the failure that started this chapter (showing Bidding rather than Winning). Now we have to make the Sniper win:

java.lang.AssertionError:尝试 在所有顶层窗口中

查找...恰好 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)中的

1 个 JLabel(名称为“sniper status”)并检查其标签文本是否为“Won”但... 所有顶层窗口均 包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)且 包含 1 个 JLabel(名称为“sniper status”)标签文本为“Lost”















java.lang.AssertionError:

Tried to look for...

exactly 1 JLabel (with name "sniper status")

in exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

and check that its label text is "Won"

but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JLabel (with name "sniper status")

label text was "Lost"

狙击手获得一些状态

The Sniper Acquires Some State

我们即将对狙击手的复杂性进行一次小幅调整。当拍卖结束时,我们希望狙击手宣布它是赢了还是输了,这意味着它必须知道当时它是在竞标还是赢了。这意味着狙击手必须保持某种状态,而到目前为止它还不需要这样做。

We’re about to introduce a step change in the complexity of the Sniper, if only a small one. When the auction closes, we want the Sniper to announce whether it has won or lost, which means that it must know whether it was bidding or winning at the time. This implies that the Sniper will have to maintain some state, which it hasn’t had to so far.

为了实现我们想要的功能,我们将从狙击手失败的简单情况开始。如图 14.2所示,我们从一步和两步转换开始,然后添加将狙击手带到以下Won状态的附加步骤:

To get to the functionality we want, we’ll start with the simpler cases where the Sniper loses. As Figure 14.2 shows, we’re starting with one- and two-step transitions, before adding the additional step that takes the Sniper to the Won state:

图 14.2 狙击手出价,然后输了

Figure 14.2 A Sniper bids, then loses

图像

我们首先重新审视现有的单元测试并添加新的单元测试。这些测试将通过当前实现;它们的存在是为了确保我们在添加更多转换时不会破坏行为。

We start by revisiting an existing unit test and adding a new one. These tests will pass with the current implementation; they’re there to ensure that we don’t break the behavior when we add further transitions.

这引入了一些新的 jMock 语法,states。这个想法是让我们能够对被测对象的内部状态做出断言。我们稍后会回到这个想法。

This introduces some new jMock syntax, states. The idea is to allow us to make assertions about the internal state of the object under test. We’ll come back to this idea in a moment.

公共类 AuctionSniperTest { [...]

私有最终状态 sniperState = context.states("sniper"); 图像



@Test public void

reportsLostIfAuctionCloses立即() { 图像

context.checking(new Expectations() {{

atLeast(1).of(sniperListener).sniperLost();

});



sniper.auctionClosed();

}



@Test public void

reportsLostIfAuctionClosesWhenBidding() {

context.checking(new Expectations() {{

ignoring(auction); 图像

allowing(sniperListener).sniperBidding();

then(sniperState.is("bidding")); 图像



atLeast(1).of(sniperListener).sniperLost();

when(sniperState.is("bidding")); 图像

}});



sniper.currentPrice(123, 45, PriceSource.FromOtherBidder); 图像

sniper.auctionClosed();

}

}

public class AuctionSniperTest { [...]

private final States sniperState = context.states("sniper");



@Test public void

reportsLostIfAuctionClosesImmediately() {

context.checking(new Expectations() {{

atLeast(1).of(sniperListener).sniperLost();

}});



sniper.auctionClosed();

}



@Test public void

reportsLostIfAuctionClosesWhenBidding() {

context.checking(new Expectations() {{

ignoring(auction);

allowing(sniperListener).sniperBidding();

then(sniperState.is("bidding"));



atLeast(1).of(sniperListener).sniperLost();

when(sniperState.is("bidding"));

}});



sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);

sniper.auctionClosed();

}

}

图像我们希望跟踪狙击手的当前状态,就像它发出的事件所表明的那样,所以我们要求context一个占位符。默认状态是null

We want to keep track of the Sniper’s current state, as signaled by the events it sends out, so we ask context for a placeholder. The default state is null.

图像我们保留原来的测试,但现在它将适用于没有价格更新的情况。

We keep our original test, but now it will apply where there are no price updates.

图像狙击手会打电话,但在这个测试中auction我们真的不关心这一点,所以我们告诉测试完全忽略这个合作者。

The Sniper will call auction but we really don’t care about that in this test, so we tell the test to ignore this collaborator completely.

图像当狙击手发出竞标事件时,它会告诉我们它处于某种bidding状态,我们会在此处记录。我们使用该allowing()子句来传达这是测试的支持部分,而不是我们真正关心的部分;请参阅下面的注释。

When the Sniper sends out a bidding event, it’s telling us that it’s in a bidding state, which we record here. We use the allowing() clause to communicate that this is a supporting part of the test, not the part we really care about; see the note below.

图像这是最重要的短语,也是我们想要断言的期望。如果狙击手在发出此呼叫时没有出价,则测试将失败。

This is the phrase that matters, the expectation that we want to assert. If the Sniper isn’t bidding when it makes this call, the test will fail.

图像这是我们的第一个测试,我们需要一系列事件来让狙击手进入我们想要测试的状态。我们只需按顺序调用它的方法即可。

This is our first test where we need a sequence of events to get the Sniper into the state we want to test. We just call its methods in order.

津贴

Allowances

图像

jMock 区分允许调用和预期调用。allowing()子句表示对象可能会进行此调用,但并非必须这样做 — 这与预期不同,预期如果不进行调用,测试将失败。我们进行区分是为了帮助表达测试中的重要内容(底层实现实际上是相同的):预期是我们想要确认发生的事情;允许是支持基础结构,可帮助测试对象进入正确状态,或者它们是我们不关心的副作用。我们将在“允许和预期” (第 277页) 中返回此主题,并在附录 A中描述 API 。

jMock distinguishes between allowed and expected invocations. An allowing() clause says that the object might make this call, but it doesn’t have to—unlike an expectation which will fail the test if the call isn’t made. We make the distinction to help express what is important in a test (the underlying implementation is actually the same): expectations are what we want to confirm to have happened; allowances are supporting infrastructure that helps get the tested objects into the right state, or they’re side effects we don’t care about. We return to this topic in “Allowances and Expectations” (page 277) and we describe the API in Appendix A.

表示对象状态

Representing Object State

图像

在这种情况下,我们希望根据对象的状态对其行为做出断言,但我们不想通过暴露该状态的实现方式来破坏封装。相反,测试可以监听 Sniper 提供的通知事件,以他们的方式告知感兴趣的协作者其状态。jMock 提供States对象,以便测试可以在发生重要事件时(即当它调用其邻居时)记录并断言对象的状态;有关语法,请参阅附录 A。

In cases like this, we want to make assertions about an object’s behavior depending on its state, but we don’t want to break encapsulation by exposing how that state is implemented. Instead, the test can listen to the notification events that the Sniper provides to tell interested collaborators about its state in their terms. jMock provides States objects, so that tests can record and make assertions about the state of an object when something significant happens, i.e. when it calls its neighbors; see Appendix A for the syntax.

这是对象(本例中为狙击手)内部情况的“逻辑”表示。它允许测试描述它发现的有关狙击手的相关内容,而不管狙击手的实际实现方式。正如您即将看到的那样,这种分离将使我们能够在不更改测试的情况下对狙击手的实现进行彻底更改。

This is a “logical” representation of what’s going on inside the object, in this case the Sniper. It allows the test to describe what it finds relevant about the Sniper, regardless of how the Sniper is actually implemented. As you’ll see shortly, this separation will allow us to make radical changes to the implementation of the Sniper without changing the tests.

单元测试名称reportsLostIfAuctionClosesWhenBidding与它执行的期望非常相似:

The unit test name reportsLostIfAuctionClosesWhenBidding is very similar to the expectation it enforces:

至少(1).of(sniperListener).sniperLost(); 当(sniperState.is("出价"))时;

atLeast(1).of(sniperListener).sniperLost(); when(sniperState.is("bidding"));

这并非偶然。我们投入了大量精力来确定 jMock 应该支持哪些抽象,并开发出一种能够表达单元测试基本意图的风格。

That’s not an accident. We put a lot of effort into figuring out which abstractions jMock should support and developing a style that expresses the essential intent of a unit test.

狙击手获胜

The Sniper Wins

最后,我们可以完成循环并让狙击手赢得竞标。下一个测试将介绍该Won事件。

Finally, we can close the loop and have the Sniper win a bid. The next test introduces the Won event.

@Test public void

reportsWonIfAuctionClosesWhenWinning() {

context.checking(new Expectations() {{

ignoring(auction);

allowing(sniperListener).sniperWinning(); then(sniperState.is("winning"));



atLeast(1).of(sniperListener). sniperWon (); when(sniperState.is("winning"));

}});

sniper.currentPrice(123, 45, PriceSource.FromSniper);

sniper.auctionClosed();

}

@Test public void

reportsWonIfAuctionClosesWhenWinning() {

context.checking(new Expectations() {{

ignoring(auction);

allowing(sniperListener).sniperWinning(); then(sniperState.is("winning"));



atLeast(1).of(sniperListener).sniperWon(); when(sniperState.is("winning"));

}});

sniper.currentPrice(123, 45, PriceSource.FromSniper);

sniper.auctionClosed();

}

它具有相同的结构,但表示狙击手获胜。测试失败,因为狙击手叫了sniperLost()

It has the same structure but represents when the Sniper has won. The test fails because the Sniper called sniperLost().

意外调用:sniperListener.sniperLost()

预期:

允许,从未调用:

拍卖。<任何方法>(<任何参数>)是[];

允许,已经调用 1 次:sniperListener.sniperWinning();

然后狙击手获胜

预计至少 1 次,从未调用:sniperListener.sniperWon();

当狙击手获胜时

状态:

狙击手获胜

在此之前发生了什么:

sniperListener.sniperWinning()

unexpected invocation: sniperListener.sniperLost()

expectations:

allowed, never invoked:

auction.<any method>(<any parameters>) was[];

allowed, already invoked 1 time: sniperListener.sniperWinning();

then sniper is winning

expected at least 1 time, never invoked: sniperListener.sniperWon();

when sniper is winning

states:

sniper is winning

what happened before this:

sniperListener.sniperWinning()

我们添加一个标志来表示狙击手的状态,并sniperWon()在中实现新方法SniperStateDisplayer

We add a flag to represent the Sniper’s state, and implement the new sniperWon() method in the SniperStateDisplayer.

公共类 AuctionSniper 实现 AuctionEventListener { [...]

私有布尔值 isWinning = false;



公共 void auctionClosed() {

如果 (isWinning) {

sniperListener.sniperWon();

} else {

sniperListener.sniperLost();

}

}

公共 void currentPrice(int price, int increase, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

如果 (isWinning) {

sniperListener.sniperWinning();

} else {

auction.bid(price + increase);

sniperListener.sniperBidding();

}

}

公共

类 SniperStateDisplayer 实现 SniperListener { [...]

公共 void sniperWon() {

showStatus(MainWindow.STATUS_WON);

}

}

public class AuctionSniper implements AuctionEventListener { [...]

private boolean isWinning = false;



public void auctionClosed() {

if (isWinning) {

sniperListener.sniperWon();

} else {

sniperListener.sniperLost();

}

}

public void currentPrice(int price, int increment, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

if (isWinning) {

sniperListener.sniperWinning();

} else {

auction.bid(price + increment);

sniperListener.sniperBidding();

}

}

}

public class SniperStateDisplayer implements SniperListener { [...]

public void sniperWon() {

showStatus(MainWindow.STATUS_WON);

}

}

之前我们曾对 大惊小怪PriceSource,我们在这里使用布尔值 是否不一致isWinning?我们的理由是,我们确实尝试过为狙击手状态使用枚举,但它看起来太复杂了。该字段是 的私有字段AuctionSniper,它足够小,因此以后很容易更改,并且代码读起来也很好。

Having previously made a fuss about PriceSource, are we being inconsistent here by using a boolean for isWinning? Our excuse is that we did try an enum for the Sniper state, but it just looked too complicated. The field is private to AuctionSniper, which is small enough so it’s easy to change later and the code reads well.

现在单元测试和端到端测试都通过了,所以我们可以从图 14.3中的待办事项列表中划掉另一项。

The unit and end-to-end tests all pass now, so we can cross off another item from the to-do list in Figure 14.3.

图 14.3 狙击手获胜

Figure 14.3 The Sniper wins

图像

我们可以编写更多测试,例如描述从竞标到获胜再到获胜的转换,但亲爱的读者,我们将把这些留给您作为练习。相反,我们将转向下一个重大的功能变化。

There are more tests we could write—for example, to describe the transitions from bidding to winning and back again, but we’ll leave those as an exercise for you, Dear Reader. Instead, we’ll move on to the next significant change in functionality.

稳步前进

Making Steady Progress

和往常一样,我们通过添加小部分功能取得了稳步进展。首先,我们让狙击手显示何时获胜,然后显示何时获胜。当我们还没有准备好填写代码时,我们使用空实现来帮助我们通过编译器,并且我们专注于眼前的任务。

As always, we made steady progress by adding little slices of functionality. First we made the Sniper show when it’s winning, then when it has won. We used empty implementations to get us through the compiler when we weren’t ready to fill in the code, and we stayed focused on the immediate task.

令人惊喜的是,现在代码量有所增长,我们开始看到我们之前的一些努力得到了回报,因为新功能刚好适合现有结构。我们必须实施的下一个任务将改变这一现状。

One of the pleasant surprises is that, now the code is growing a little, we’re starting to see some of our earlier effort pay off as new features just fit into the existing structure. The next tasks we have to implement will shake this up.

第 15 章 迈向真实的用户界面

Chapter 15. Towards a Real User Interface

我们将用户界面从标签扩展到表格。我们通过一次添加一个功能来实现这一点,而不是冒险一次性替换整个界面。我们发现我们做出的一些选择不再有效,所以我们敢于更改现有代码。我们继续重构,并感觉到一个更有趣的结构开始出现。

In which we grow the user interface from a label to a table. We achieve this by adding a feature at a time, instead of taking the risk of replacing the whole thing in one go. We discover that some of the choices we made are no longer valid, so we dare to change existing code. We continue to refactor and sense that a more interesting structure is starting to appear.

更现实的实施

A More Realistic Implementation

我们下一步该做什么?

What Do We Have to Do Next?

到目前为止,我们一直在用户界面中使用一个简单的标签。这对于帮助我们阐明应用程序的结构并证明我们的想法是有效的,但接下来的任务需要更多,客户希望看到更接近图 9.1 的内容。我们需要显示更多拍卖价格详情并处理多个物品。

So far, we’ve been making do with a simple label in the user interface. That’s been effective for helping us clarify the structure of the application and prove that our ideas work, but the next tasks coming up will need more, and the client wants to see something that looks closer to Figure 9.1. We will need to show more price details from the auction and handle multiple items.

最简单的选择就是在标签中添加更多文本,但我们认为这是在用户界面中引入更多结构的最佳时机。我们推迟了对应用程序这一部分的投入,我们认为现在应该迎头赶上,为即将实现的更复杂的要求做好准备。考虑到我们使用 Swing,我们决定做出显而易见的选择,用表格组件替换标签。这个决定为我们下一步的设计指明了方向。

The simplest option would be just to add more text into the label, but we think this is the right time to introduce more structure into the user interface. We deferred putting effort into this part of the application, and we think we should catch up now to be ready for the more complex requirements we’re about to implement. We decide to make the obvious choice, given our use of Swing, and replace the label with a table component. This decision gives us a clear direction for where our design should go next.

使用 的 Swing 模式JTable是将其与 关联TableModel。表组件查询模型以获取要显示的值,并且当这些值发生变化时,模型会通知表。在我们的应用程序中,关系将如图 15.1所示。我们将 称为新类,SnipersTableModel因为我们希望它支持多个狙击手。它将接受来自狙击手的更新,并向其 提供这些值的表示JTable

The Swing pattern for using a JTable is to associate it with a TableModel. The table component queries the model for values to present, and the model notifies the table when those values change. In our application, the relationships will look like Figure 15.1. We call the new class SnipersTableModel because we want it to support multiple Snipers. It will accept updates from the Snipers and provide a representation of those values to its JTable.

图 15.1 摆动台模型 AuctionSniper

Figure 15.1 Swing table model for the AuctionSniper

图像

问题是如何从这里到达那里。

The question is how to get there from here.

替换 JLabel

Replacing JLabel

我们希望以最少的改动将各个部分整合到位,而不会破坏整个应用程序。我们能想到的最小步骤是将现有实现 (a JLabel) 替换为单个单元格JTable,然后我们可以从中增加其他功能。当然,我们从测试开始,将我们的线束更改为在表中查找单元格,而不是标签。

We want to get the pieces into place with a minimum of change, without tearing the whole application apart. The smallest step we can think of is to replace the existing implementation (a JLabel) with a single-cell JTable, from which we can then grow the additional functionality. We start, of course, with the test, changing our harness to look for a cell in a table, rather than a label.

公共类 AuctionSniperDriver 扩展了 JFrameDriver { [...]



公共 void showsSniperStatus(String statusText) {

新的 JTableDriver(this).hasCell(withLabelText(equalTo(statusText)));

}

}

public class AuctionSniperDriver extends JFrameDriver { [...]



public void showsSniperStatus(String statusText) {

new JTableDriver(this).hasCell(withLabelText(equalTo(statusText)));

}

}

由于我们还没有表,因此这会生成一条失败消息。

This generates a failure message because we don’t yet have a table.

[...]但是...

所有顶层窗口

均包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)

且包含 0 个 JTable()

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 0 JTable ()

我们通过改造最小JTable实现来修复此测试。从现在开始,我们想加快叙述速度,所以我们只展示最终结果。如果我们感到谨慎,我们会首先添加一个空表,以修复即时故障,然后添加其内容。事实证明,我们不必更改外部任何现有类,MainWindow因为它封装了更新状态的行为。这是新代码:

We fix this test by retrofitting a minimal JTable implementation. From now on, we want to speed up our narrative, so we’ll just show the end result. If we were feeling cautious we would first add an empty table, to fix the immediate failure, and then add its contents. It turns out that we don’t have to change any existing classes outside MainWindow because it encapsulates the act of updating the status. Here’s the new code:

公共类 MainWindow 扩展了 JFrame { [...]

私有最终 SnipersTableModel snipers = new SnipersTableModel();



公共 MainWindow() {

超(APPLICATION_TITLE);

setName(MainWindow.MAIN_WINDOW_NAME);

fillContentPane(makeSnipersTable());

pack();

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setVisible(true);

}



私有 void fillContentPane(JTable snipersTable) {

最终容器 contentPane = getContentPane();

contentPane.setLayout(new BorderLayout());



contentPane.add(new JScrollPane(snipersTable),BorderLayout.CENTER);

}



私有 JTable makeSnipersTable() {

最终 JTable snipersTable = new JTable(snipers);

snipersTable.setName(SNIPERS_TABLE_NAME);

返回 snipersTable;

}



public void showStatusText(String statusText) {

snipers.setStatusText(statusText);

}

}



public class SnipersTableModel 扩展了 AbstractTableModel {

private String statusText = STATUS_JOINING;



public int getColumnCount() { return 1; }

public int getRowCount() { return 1; }

public Object getValueAt(int rowIndex, int columnIndex) { return statusText; }



public void setStatusText(String newStatusText) {

statusText = newStatusText;

fireTableRowsUpdated(0, 0);

}

}

public class MainWindow extends JFrame { [...]

private final SnipersTableModel snipers = new SnipersTableModel();



public MainWindow() {

super(APPLICATION_TITLE);

setName(MainWindow.MAIN_WINDOW_NAME);

fillContentPane(makeSnipersTable());

pack();

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setVisible(true);

}



private void fillContentPane(JTable snipersTable) {

final Container contentPane = getContentPane();

contentPane.setLayout(new BorderLayout());



contentPane.add(new JScrollPane(snipersTable), BorderLayout.CENTER);

}



private JTable makeSnipersTable() {

final JTable snipersTable = new JTable(snipers);

snipersTable.setName(SNIPERS_TABLE_NAME);

return snipersTable;

}



public void showStatusText(String statusText) {

snipers.setStatusText(statusText);

}

}



public class SnipersTableModel extends AbstractTableModel {

private String statusText = STATUS_JOINING;



public int getColumnCount() { return 1; }

public int getRowCount() { return 1; }

public Object getValueAt(int rowIndex, int columnIndex) { return statusText; }



public void setStatusText(String newStatusText) {

statusText = newStatusText;

fireTableRowsUpdated(0, 0);

}

}

仍然丑陋

Still Ugly

如您所见,这SnipersTableModel确实是一个最小实现;唯一可以变化的值是statusText。它从 Swing 继承了大部分行为AbstractTableModel,包括用于通知数据更改的基础结构JTable。结果与我们以前的版本一样丑陋,只是现在JTable添加了默认列标题“A”,如图15.2所示。我们稍后将进行演示。

As you can see, the SnipersTableModel really is a minimal implementation; the only value that can vary is the statusText. It inherits most of its behavior from the Swing AbstractTableModel, including the infrastructure for notifying the JTable of data changes. The result is as ugly as our previous version, except that now the JTable adds a default column title “A”, as in Figure 15.2. We’ll work on the presentation in a moment.

图 15.2 带有单元格表格的狙击手

Figure 15.2 Sniper with a single-cell table

图像

显示价格详情

Displaying Price Details

一、测试失败

First, a Failing Test

我们的下一个任务是显示有关狙击手在拍卖中的位置的信息:物品标识符、上次拍卖价格、上次出价、状态。这些值来自拍卖的更新和应用程序内保存的状态。我们需要将它们从源传递到表模型,然后在显示中呈现它们。当然,我们从测试开始。鉴于此功能应成为应用程序基本功能的一部分,而不是与我们已有的功能分开,我们更新了现有的验收测试 - 仅从一个测试开始,这样我们就不会一次破坏所有内容。这是新版本:

Our next task is to display information about the Sniper’s position in the auction: item identifier, last auction price, last bid, status. These values come from updates from the auction and the state held within the application. We need to pass them through from their source to the table model and then render them in the display. Of course, we start with the test. Given that this feature should be part of the basic functionality of the application, not separate from what we already have, we update our existing acceptance tests—starting with just one test so we don’t break everything at once. Here’s the new version:

public class AuctionSniperEndToEndTest {

@Test public void

sniperWinsAnAuctionByBiddingHigher() throws Exception {

auction.startSellingItem();



application.startBiddingIn(auction);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1000, 98, "其他竞标者");

application.hasShownSniperIsBidding( 1000, 1098 ); // 最新价格,最新出价



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);

application.hasShownSniperIsWinning( 1098 ); // 中标



auction.announceClosed();

application.showsSniperHasWonAuction( 1098 ); // 最新价格

}

}

public class ApplicationRunner {

private String itemId;



public void startBiddingIn(final FakeAuctionServer auction) {

itemId = auction.getItemId();

[...]

}



[...]

public void hasShownSniperIsBidding( int lastPrice, int lastBid ) {

driver.showsSniperStatus( itemId, lastPrice, lastBid,

MainWindow.STATUS_BIDDING);

}



public void hasShownSniperIsWinning(int winningBid ) {

driver.showsSniperStatus( itemId, winningBid, winningBid,

MainWindow.STATUS_WINNING);

}



public void showsSniperHasWonAuction(int lastPrice ) {

driver.showsSniperStatus( itemId, lastPrice, lastPrice,

MainWindow.STATUS_WON);

}

}



public class AuctionSniperDriver 扩展了 JFrameDriver {

[...]

public void showsSniperStatus(String itemId, int lastPrice, int lastBid,

String statusText)

{

JTableDriver table = new JTableDriver(this);

table.hasRow(

matching(withLabelText(itemId), withLabelText(valueOf(lastPrice)),

withLabelText(valueOf(lastBid)), withLabelText(statusText)));

}

}

public class AuctionSniperEndToEndTest {

@Test public void

sniperWinsAnAuctionByBiddingHigher() throws Exception {

auction.startSellingItem();



application.startBiddingIn(auction);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1000, 98, "other bidder");

application.hasShownSniperIsBidding(1000, 1098); // last price, last bid



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);

application.hasShownSniperIsWinning(1098); // winning bid



auction.announceClosed();

application.showsSniperHasWonAuction(1098); // last price

}

}

public class ApplicationRunner {

private String itemId;



public void startBiddingIn(final FakeAuctionServer auction) {

itemId = auction.getItemId();

[...]

}



[...]

public void hasShownSniperIsBidding(int lastPrice, int lastBid) {

driver.showsSniperStatus(itemId, lastPrice, lastBid,

MainWindow.STATUS_BIDDING);

}



public void hasShownSniperIsWinning(int winningBid) {

driver.showsSniperStatus(itemId, winningBid, winningBid,

MainWindow.STATUS_WINNING);

}



public void showsSniperHasWonAuction(int lastPrice) {

driver.showsSniperStatus(itemId, lastPrice, lastPrice,

MainWindow.STATUS_WON);

}

}



public class AuctionSniperDriver extends JFrameDriver {

[...]

public void showsSniperStatus(String itemId, int lastPrice, int lastBid,

String statusText)

{

JTableDriver table = new JTableDriver(this);

table.hasRow(

matching(withLabelText(itemId), withLabelText(valueOf(lastPrice)),

withLabelText(valueOf(lastBid)), withLabelText(statusText)));

}

}

我们需要物品标识符,以便测试可以在行中查找它,因此我们ApplicationRunner在连接到拍卖时对其进行了保留。我们扩展了AuctionSniperDriver查找显示物品标识符、最后价格、最后出价和狙击状态的表格行。

We need the item identifier so the test can look for it in the row, so we make the ApplicationRunner hold on it when connecting to an auction. We extend the AuctionSniperDriver to look for a table row that shows the item identifier, last price, last bid, and sniper status.

测试失败,因为该行没有详细信息,只有状态文本:

The test fails because the row has no details, only the status text:

[...]但是...

所有顶层窗口都

包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上),

其中包含 1 个 JTable()

它不包含单元格

<label with text "item-54321">, <label with text "1000">,

<label with text "1098">, <label with text "Bidding"> 的行

,因为

在第 0 行:组件 0 文本为“Bidding”

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JTable ()

it is not with row with cells

<label with text "item-54321">, <label with text "1000">,

<label with text "1098">, <label with text "Bidding">

because

in row 0: component 0 text was "Bidding"

让国家摆脱狙击手

Sending the State out of the Sniper

通过验收测试来展示我们想要到达的地方,我们可以填写沿途的步骤。像往常一样,我们从触发行为的事件开始“由外而内”地工作;在本例中,它是来自 Southabee 的 On-Line 的价格更新。按照方法调用的顺序,我们不必更改AuctionMessageTranslator,因此我们首先查看AuctionSniper及其单元测试。

With an acceptance test to show us where we want to get to, we can fill in the steps along the way. As usual, we work “outside-in,” from the event that triggers the behavior; in this case it’s a price update from Southabee’s On-Line. Following along the sequence of method calls, we don’t have to change AuctionMessageTranslator, so we start by looking at AuctionSniper and its unit tests.

AuctionSniper将其状态变化通知给实现SniperListener接口的邻居,您可能还记得,该接口有四个回调方法,每个方法对应狙击手的一个状态。现在,当我们通知侦听器时,我们还需要在狙击手的当前状态中传递。我们可以向每个方法添加相同的参数集,但那会造成重复;因此,我们引入了一个值类型来承载狙击手的状态。这是我们在“值类型” (第59页) 中描述的“捆绑”示例。以下是第一个示例:

AuctionSniper notifies changes in its state to neighbors that implement the SniperListener interface which, as you might remember, has four callback methods, one for each state of the Sniper. Now we also need to pass in the current state of the Sniper when we notify a listener. We could add the same set of arguments to each method, but that would be duplication; so, we introduce a value type to carry the Sniper’s state. This is an example of “bundling up” that we described in “Value Types” (page 59). Here’s a first cut:

公共类 SniperState {

公共最终字符串 itemId;

公共最终 int lastPrice;

公共最终 int lastBid;



公共 SniperState(字符串 itemId, int lastPrice, int lastBid){

this.itemId = itemId;

this.lastPrice = lastPrice;

this.lastBid = lastBid;

}

}

public class SniperState {

public final String itemId;

public final int lastPrice;

public final int lastBid;



public SniperState(String itemId, int lastPrice, int lastBid) {

this.itemId = itemId;

this.lastPrice = lastPrice;

this.lastBid = lastBid;

}

}

为了节省精力,我们使用 Apachecommons.lang库中的反射构建器在新类中实现equals()hashCode()toString()。我们可能会说我们在使用这些功能方面还为时过早,但实际上,我们在编写单元测试时会需要它们。

To save effort, we use the reflective builders from the Apache commons.lang library to implement equals(), hashCode(), and toString() in the new class. We could argue that we’re being premature with these features, but in practice we’ll need them in a moment when we write our unit tests.

公共 final 字段

Public Final Fields

图像

我们已经养成了在值类型中使用公共 final 字段的习惯,至少在我们正在整理类型应该做什么的过程中。这清楚地表明该值是不可变的,并减少了在类尚未稳定时维护 getter 的开销。我们的目标是用类型上有意义的操作方法替换所有字段访问,尽管我们可能无法实现。我们将看看结果如何。

We’ve adopted a habit of using public final fields in value types, at least while we’re in the process of sorting out what the type should do. It makes it obvious that the value is immutable and reduces the overhead of maintaining getters when the class isn’t yet stable. Our ambition, which we might not achieve, is to replace all field access with meaningful action methods on the type. We’ll see how that pans out.

我们不想一次性破坏所有测试,因此我们从一个简单的测试开始。在这个测试中没有历史记录,我们在 Sniper 中要做的就是SniperState根据当时可用的信息构建一个并将其传递给侦听器。

We don’t want to break all the tests at once, so we start with an easy one. In this test there’s no history, all we have to do in the Sniper is construct a SniperState from information available at the time and pass it to the listener.

公共类 AuctionSniperTest { [...]

@Test public void

bidsHigherAndReportsBiddingWhenNewPriceArrives() {

final int price = 1001;

final int increase = 25;

final int bid = price + increase;



context.checking(new Expectations() {{

one(auction).bid(bid);

atLeast(1).of(sniperListener).sniperBidding(

new SniperState(ITEM_ID, price, bid));

}});



sniper.currentPrice(price, increase, PriceSource.FromOtherBidder);

}

}

public class AuctionSniperTest { [...]

@Test public void

bidsHigherAndReportsBiddingWhenNewPriceArrives() {

final int price = 1001;

final int increment = 25;

final int bid = price + increment;



context.checking(new Expectations() {{

one(auction).bid(bid);

atLeast(1).of(sniperListener).sniperBidding(

new SniperState(ITEM_ID, price, bid));

}});



sniper.currentPrice(price, increment, PriceSource.FromOtherBidder);

}

}

然后我们让测试通过:

Then we make the test pass:

公共类 AuctionSniper 实现 AuctionEventListener { [...]

public void currentPrice(int price, int increase, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

if (isWinning) {

sniperListener.sniperWinning();

} else {

int bid = price + increase;

auction.bid(bid);

sniperListener.sniperBidding( new SniperState(itemId, price, bid) );

}

}

}

public class AuctionSniper implements AuctionEventListener { [...]

public void currentPrice(int price, int increment, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

if (isWinning) {

sniperListener.sniperWinning();

} else {

int bid = price + increment;

auction.bid(bid);

sniperListener.sniperBidding(new SniperState(itemId, price, bid));

}

}

}

为了使代码可以编译,我们还将状态参数添加到实现的sniperBidding()方法中,但尚未对其执行任何操作。SniperStateDisplayerSniperListener

To get the code to compile, we also add the state argument to the sniperBidding() method in SniperStateDisplayer, which implements SniperListener, but don’t yet do anything with it.

一个重大变化是狙击手需要访问项目标识符,以便它可以构造一个SniperState。鉴于狙击手不需要这个值,我们可以将其保留在SniperStateDisplayer并在事件通过时添加它,但我们认为狙击手有权访问此信息是合理的。我们决定将标识符传递给AuctionSniper构造函数;它当时可用,我们不想从Auction可能具有自己的项目标识符形式的对象中获取它。

The one significant change is that the Sniper needs access to the item identifier so it can construct a SniperState. Given that the Sniper doesn’t need this value for any other reason, we could have kept it in the SniperStateDisplayer and added it in when an event passes through, but we think it’s reasonable that the Sniper has access to this information. We decide to pass the identifier into the AuctionSniper constructor; it’s available at the time, and we don’t want to get it from the Auction object which may have its own form of identifier for an item.

我们还有另一个测试引用该sniperBidding()方法,但仅作为“允许”。我们使用一个匹配器,它表示,由于它仅支持测试中有趣的部分,所以我们不关心状态对象的内容。

We have one other test that refers to the sniperBidding() method, but only as an “allowance.” We use a matcher that says that, since it’s only supporting the interesting part of the test, we don’t care about the contents of the state object.

允许(sniperListener)。sniperBidding(与任何(SniperState.class)));

allowing(sniperListener).sniperBidding(with(any(SniperState.class)));

显示竞标狙击手

Showing a Bidding Sniper

我们将采取更大的步骤来完成下一个任务——在用户界面中呈现状态——因为有一些新的活动部件,包括一个新的单元测试。代码的第一个版本会比我们想象的要笨拙,但正如您很快就会看到的,将会有有趣的机会进行清理。

We’ll take larger steps for the next task—presenting the state in the user interface—as there are some new moving parts, including a new unit test. The first version of the code will be clumsier than we would like but, as you’ll soon see, there’ll be interesting opportunities for cleaning up.

我们的第一步是将我们一直忽略的新状态参数传递MainWindow给 中的新方法SnipersTableModel。在此过程中,我们注意到仅传递事件MainWindow并没有增加太多价值,因此我们记下来以后再处理。

Our very first step is to pass the new state parameter, which we’ve been ignoring, through MainWindow to a new method in SnipersTableModel. While we’re at it, we notice that just passing events through MainWindow isn’t adding much value, so we make a note to deal with that later.

公共类 SniperStateDisplayer 实现 SniperListener { [...]

公共 void sniperBidding(最终 SniperState 状态) {

SwingUtilities.invokeLater(新 Runnable() {

公共 void run() {

ui.sniperStatusChanged(状态,MainWindow.STATUS_BIDDING);

}

});

}

}



公共类 MainWindow 扩展 JFrame { [...]

公共 void sniperStatusChanged(SniperState sniperState,String statusText) {

snipers.sniperStatusChanged(sniperState,statusText);

}

}

public class SniperStateDisplayer implements SniperListener { [...]

public void sniperBidding(final SniperState state) {

SwingUtilities.invokeLater(new Runnable() {

public void run() {

ui.sniperStatusChanged(state, MainWindow.STATUS_BIDDING);

}

});

}

}



public class MainWindow extends JFrame { [...]

public void sniperStatusChanged(SniperState sniperState, String statusText) {

snipers.sniperStatusChanged(sniperState, statusText);

}

}

为了使新值在屏幕上可见,我们需要进行修复SnipersTableModel,以便它能够JTable从单元测试开始使用这些值。我们通过引入 Javaenum来表示表中的列,实现了一个小的设计飞跃——这比仅仅使用整数更有意义。

To get the new values visible on screen, we need to fix SnipersTableModel so that it makes them available to its JTable, starting with a unit test. We take a small design leap by introducing a Java enum to represent the columns in the table—it’s more meaningful than just using integers.

公共枚举列 {

ITEM_IDENTIFIER,

LAST_PRICE,

LAST_BID,

SNIPER_STATUS;



公共静态列 at(int offset) { 返回值()[offset]; }

}

public enum Column {

ITEM_IDENTIFIER,

LAST_PRICE,

LAST_BID,

SNIPER_STATUS;



public static Column at(int offset) { return values()[offset]; }

}

当表模型的状态发生变化时,它需要做两件事:保存新值并通知表它们已更改。以下是测试:

The table model needs to do two things when its state changes: hold onto the new values and notify the table that they’ve changed. Here’s the test:

@RunWith(JMock.class)

公共类 SnipersTableModelTest {

私有最终 Mockery 上下文 = new Mockery();

私有 TableModelListener 监听器 = context.mock(TableModelListener.class);

私有最终 SnipersTableModel 模型 = new SnipersTableModel();



@Before 公共 void attachmentModelListener() { 图像

model.addTableModelListener(listener);

}



@Test 公共 void

hasEnoughColumns() { 图像

assertThat(model.getColumnCount(), equalTo(Column.values().length));

}

@Test 公共 void

setsSniperValuesInColumns() {

context.checking(new Expectations() {{

one(listener).tableChanged(with(aRowChangedEvent())); 图像

}});



model.sniperStatusChanged(new SniperState("item id", 555, 666), 图像

MainWindow.STATUS_BIDDING);



assertColumnEquals(Column.ITEM_IDENTIFIER, "item id");图像

断言ColumnEquals(Column.LAST_PRICE,555);

断言ColumnEquals(Column.LAST_BID,666);

断言ColumnEquals(Column.SNIPER_STATUS,MainWindow.STATUS_BIDDING);

}



private void assertColumnEquals(Column column,Object expected){

final int rowIndex = 0;

final int columnIndex = column.ordinal();

断言Equals(expected,model.getValueAt(rowIndex,columnIndex);

}



private Matcher<TableModelEvent> aRowChangedEvent(){ 图像

return samePropertyValuesAs(new TableModelEvent(model,0));

}

}

@RunWith(JMock.class)

public class SnipersTableModelTest {

private final Mockery context = new Mockery();

private TableModelListener listener = context.mock(TableModelListener.class);

private final SnipersTableModel model = new SnipersTableModel();



@Before public void attachModelListener() {

model.addTableModelListener(listener);

}



@Test public void

hasEnoughColumns() {

assertThat(model.getColumnCount(), equalTo(Column.values().length));

}

@Test public void

setsSniperValuesInColumns() {

context.checking(new Expectations() {{

one(listener).tableChanged(with(aRowChangedEvent()));

}});



model.sniperStatusChanged(new SniperState("item id", 555, 666),

MainWindow.STATUS_BIDDING);



assertColumnEquals(Column.ITEM_IDENTIFIER, "item id");

assertColumnEquals(Column.LAST_PRICE, 555);

assertColumnEquals(Column.LAST_BID, 666);

assertColumnEquals(Column.SNIPER_STATUS, MainWindow.STATUS_BIDDING);

}



private void assertColumnEquals(Column column, Object expected) {

final int rowIndex = 0;

final int columnIndex = column.ordinal();

assertEquals(expected, model.getValueAt(rowIndex, columnIndex);

}



private Matcher<TableModelEvent> aRowChangedEvent() {

return samePropertyValuesAs(new TableModelEvent(model, 0));

}

}

图像我们将的模拟实现附加到模型中。这是我们打破规则“仅模拟您拥有的类型” (第69TableModelListener页)的少数情况之一,因为表模型设计非常符合我们的设计方法。

We attach a mock implementation of TableModelListener to the model. This is one of the few occasions where we break our rule “Only Mock Types That You Own” (page 69) because the table model design fits our design approach so well.

图像我们添加第一个测试,以确保我们渲染的列数正确。稍后,我们将对列标题进行一些处理。

We add a first test to make sure we’re rendering the right number of columns. Later, we’ll do something about the column titles.

图像这个期望检查我们是否通知任何附件JTable内容已经发生了变化。

This expectation checks that we notify any attached JTable that the contents have changed.

图像这是触发我们想要测试的行为的事件。

This is the event that triggers the behavior we want to test.

图像我们断言表格模型在正确的列中返回正确的值。我们对行号进行硬编码,因为我们仍然假设只有一个行号。

We assert that the table model returns the right values in the right columns. We hard-code the row number because we’re still assuming that there is only one.

图像没有特定的equals()方法TableModelEvent,因此我们使用一个匹配器,它将反射性地将其收到的任何事件的属性值与预期示例进行比较。同样,我们对行号进行硬编码。

There’s no specific equals() method on TableModelEvent, so we use a matcher that will reflectively compare the property values of any event it receives against an expected example. Again, we hard-code the row number.

经过通常的红/绿循环后,我们最终得到如下实现:

After the usual red/green cycle, we end up with an implementation that looks like this:

public class SnipersTableModel extends AbstractTableModel {

private final static SniperState STARTING_UP = new SniperState("", 0, 0);

private String statusText = MainWindow.STATUS_JOINING;

private SniperState sniperState = STARTING_UP; 图像

[...]

public int getColumnCount() { 图像

return Column.values().length;

}

public int getRowCount() {

return 1;

}

public Object getValueAt(int rowIndex, int columnIndex) { 图像

switch (Column.at(columnIndex)) {

case ITEM_IDENTIFIER:

return sniperState.itemId;

case LAST_PRICE:

return sniperState.lastPrice;

case LAST_BID:

return sniperState.lastBid;

case SNIPER_STATUS:

return statusText;

default:

throw new IllegalArgumentException("没有列在 " + columnIndex);

}

}

public void sniperStatusChanged(SniperState newSniperState, 图像

String newStatusText)

{

sniperState = newSniperState;

statusText = newStatusText;

fireTableRowsUpdated(0, 0);

}

}

public class SnipersTableModel extends AbstractTableModel {

private final static SniperState STARTING_UP = new SniperState("", 0, 0);

private String statusText = MainWindow.STATUS_JOINING;

private SniperState sniperState = STARTING_UP;

[...]

public int getColumnCount() {

return Column.values().length;

}

public int getRowCount() {

return 1;

}

public Object getValueAt(int rowIndex, int columnIndex) {

switch (Column.at(columnIndex)) {

case ITEM_IDENTIFIER:

return sniperState.itemId;

case LAST_PRICE:

return sniperState.lastPrice;

case LAST_BID:

return sniperState.lastBid;

case SNIPER_STATUS:

return statusText;

default:

throw new IllegalArgumentException("No column at " + columnIndex);

}

}

public void sniperStatusChanged(SniperState newSniperState,

String newStatusText)

{

sniperState = newSniperState;

statusText = newStatusText;

fireTableRowsUpdated(0, 0);

}

}

图像我们提供一个SniperState具有“空”值的初始值,以便表格模型可以在狙击手连接之前工作。

We provide an initial SniperState with “empty” values so that the table model will work before the Sniper has connected.

图像对于维度,我们仅返回值的数量Column或硬编码的行数。

For the dimensions, we just return the numbers of values in Column or a hard-coded row count.

图像此方法根据指定的列解包要返回的值。使用的优点enum是编译器将帮助解决switch语句中缺少的分支(尽管它仍然坚持使用默认情况)。我们不太喜欢使用switch,因为它不是面向对象的,所以我们也会关注这一点。

This method unpacks the value to return depending on the column that is specified. The advantage of using an enum is that the compiler will help with missing branches in the switch statement (although it still insists on a default case). We’re not keen on using switch, as it’s not object-oriented, so we’ll keep an eye on this too.

图像Sniper 特有的方法。它设置字段,然后触发其客户端进行更新。

The Sniper-specific method. It sets the fields and then triggers its clients to update.

如果我们再次运行验收测试,我们会发现取得了一些进展。它通过了Bidding检查,现在失败了,因为最后一个价格列“B”尚未更新。有趣的是,状态列显示Winning正确,因为该代码仍在运行。

If we run our acceptance test again, we find we’ve made some progress. It’s gone past the Bidding check and now fails because the last price column, “B”, has not yet been updated. Interestingly, the status column shows Winning correctly, because that code is still working.

[...]但是...

所有顶层窗口都

包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)

包含 1 个 JTable()

它不包含单元格

<label with text "item-54321">, <label with text "1098">,

<label with text "1098">, <label with text "Winning"> 的行,

因为

在第 0 行:组件 1 的文本为“1000”

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JTable ()

it is not with row with cells

<label with text "item-54321">, <label with text "1098">,

<label with text "1098">, <label with text "Winning">

because

in row 0: component 1 text was "1000"

证明如图 15.3所示。

and the proof is in Figure 15.3.

图 15.3 狙击手显示一行细节

Figure 15.3 Sniper showing a row of detail

图像

简化狙击手活动

Simplifying Sniper Events

聆听心情音乐

Listening to the Mood Music

我们有一种狙击手事件,Bidding,我们可以通过我们的应用程序来处理它。现在我们必须对 、 和 做同样WinningLost事情Won

We have one kind of Sniper event, Bidding, that we can handle all the way through our application. Now we have to do the same thing to Winning, Lost, and Won.

坦白说,这很无聊。要使其他情况正常工作,需要做太多重复工作——在 Sniper 中设置它们并将它们传递到各个层。设计出了问题。我们反复考虑了一段时间,最终发现,如果继续这样做,我们的代码中就会出现微妙的重复。我们会将 Sniper 状态的传输分为两种机制:侦听器方法的选择和状态对象。这种机制太多了。

Frankly, that’s just dull. There’s too much repetitive work needed to make the other cases work—setting them up in the Sniper and passing them through the layers. Something’s wrong with the design. We toss this one around for a while and eventually notice that we would have a subtle duplication in our code if we just carried on. We would be splitting the transmission of the Sniper state into two mechanisms: the choice of listener method and the state object. That’s one mechanism too many.

我们意识到,我们可以将事件合并为一个包含价格狙击手状态的通知。当然,无论选择哪种机制,我们都会传输相同的信息 - 但是,看看方法调用链,只使用一个方法并将所有内容传递进去会更简单SniperState

We realize that we could collapse our events into one notification that includes the prices and the Sniper status. Of course we’re transmitting the same information whichever mechanism we choose—but, looking at the chain of methods calls, it would be simpler to have just one method and pass everything through in SniperState.

既然已经做出了选择,我们能否在不破坏隐喻的地板的情况下干净利落地完成任务呢?我们相信我们可以——但首先,我们再澄清一点。

Having made this choice, can we do it cleanly without ripping up the metaphorical floorboards? We believe we can—but first, one more clarification.

我们想首先创建一个类型来表示狙击手在拍卖中的状态(获胜、失败等),但“状态”和“状态”这两个术语太接近,难以区分。我们讨论了一些词汇,最终决定我们现在所说的更好的术语SniperStateSniperSnapshot:描述狙击手此时与拍卖的关系。这释放了名称SniperState来描述狙击手是获胜、失败等,这与我们绘制的状态机的术语相匹配参见第 78页的图 9.3。重命名需要一点时间,我们将 中的值从更改为。SniperStateColumnSNIPER_STATUSSNIPER_STATE

We want to start by creating a type to represent the Sniper’s status (winning, losing, etc.) in the auction, but the terms “status” and “state” are too close to distinguish easily. We kick around some vocabulary and eventually decide that a better term for what we now call SniperState would be SniperSnapshot: a description of the Sniper’s relationship with the auction at this moment in time. This frees up the name SniperState to describe whether the Sniper is winning, losing, and so on, which matches the terminology of the state machine we drew in Figure 9.3 on page 78. Renaming the SniperState takes a moment, and we change the value in Column from SNIPER_STATUS to SNIPER_STATE.

20/20 后见之明

20/20 Hindsight

图像

我们刚刚经历了两次这样的令人震惊的时刻,这让我们想知道为什么我们没有在第一次就发现它。当然,如果我们在设计上花更多的时间,我们现在就不必改变它了?有时确实如此。然而,我们的经验是,没有什么比尝试实现设计更能撼动设计了,我们之间只知道少数几个足够聪明的人,他们的设计总是正确的。我们的应对机制是尽早进入代码的关键区域,并允许我们在可以做得更好时改变我们的集体想法。我们依靠我们的技能、采取小步骤和测试来保护我们进行更改。

We’ve just gone through not one but two of those forehead-slapping moments that make us wonder why we didn’t see it the first time around. Surely, if we’d spent more time on the design, we wouldn’t have to change it now? Sometimes that’s true. Our experience, however, is that nothing shakes out a design like trying to implement it, and between us we know just a handful of people who are smart enough to get their designs always right. Our coping mechanism is to get into the critical areas of the code early and to allow ourselves to change our collective mind when we could do better. We rely on our skills, on taking small steps, and on the tests to protect us when we make changes.

重新利用 sniperBidding()

Repurposing sniperBidding()

我们的第一步是采用能够完成我们大部分工作的方法sniperBidding(),并重新设计它以适应我们的新方案。我们创建一个,enum它采用SniperState我们刚刚释放的名称并将其添加到SniperSnapshot;我们sniperState从方法参数中取出字段;最后,我们将方法重命名为sniperStateChanged()以匹配其预期的新角色。我们推动更改以获得以下代码:

Our first step is to take the method that does most of what we want, sniperBidding(), and rework it to fit our new scheme. We create an enum that takes the SniperState name we’ve just freed up and add it to SniperSnapshot; we take the sniperState field out of the method arguments; and, finally, we rename the method to sniperStateChanged() to match its intended new role. We push the changes through to get the following code:

公共枚举 SniperState {

加入,

竞标,

获胜,

失败,

获胜;

}



公共类 AuctionSniper 实现 AuctionEventListener { [...]

public void currentPrice(int price, int increase, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

if (isWinning) {

sniperListener.sniperWinning();

} else {

final int bid = price + increase;

auction.bid(bid);

sniperListener.sniperStateChanged(

new SniperSnapshot(itemId, price, bid, SniperState.BIDDING ));

}

}

}

public enum SniperState {

JOINING,

BIDDING,

WINNING,

LOST,

WON;

}



public class AuctionSniper implements AuctionEventListener { [...]

public void currentPrice(int price, int increment, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

if (isWinning) {

sniperListener.sniperWinning();

} else {

final int bid = price + increment;

auction.bid(bid);

sniperListener.sniperStateChanged(

new SniperSnapshot(itemId, price, bid, SniperState.BIDDING));

}

}

}

在表格模型中,我们使用简单索引将其转换enum为可显示的文本。

In the table model, we use simple indexing to translate the enum into displayable text.

公共类 SnipersTableModel 扩展了 AbstractTableModel { [...]



私有静态 String[] STATUS_TEXT = { MainWindow.STATUS_JOINING,

MainWindow.STATUS_BIDDING };

公共 void sniperStateChanged(SniperSnapshot newSnapshot) {

this.snapshot = newSnapshot;

this.state = STATUS_TEXT[newSnapshot.state.ordinal()];



fireTableRowsUpdated(0, 0);

}

}

public class SnipersTableModel extends AbstractTableModel { [...]



private static String[] STATUS_TEXT = { MainWindow.STATUS_JOINING,

MainWindow.STATUS_BIDDING };

public void sniperStateChanged(SniperSnapshot newSnapshot) {

this.snapshot = newSnapshot;

this.state = STATUS_TEXT[newSnapshot.state.ordinal()];



fireTableRowsUpdated(0, 0);

}

}

我们对测试代码做了一些小改动,以使其通过编译器,另外还做了一个有趣的调整。您可能还记得,我们写了一个忽略细节的期望子句SniperState

We make some minor changes to the test code, to get it through the compiler, plus one more interesting adjustment. You might remember that we wrote an expectation clause that ignored the details of the SniperState:

允许(sniperListener)。sniperBidding(与(任何(SniperState.class)));

allowing(sniperListener).sniperBidding(with(any(SniperState.class)));

我们不能再依赖方法的选择来区分不同的事件,所以我们必须深入研究新SniperSnapshot对象以确保我们匹配正确的对象。我们使用仅检查状态的自定义匹配器重写了期望:

We can no longer rely on the choice of method to distinguish between different events, so we have to dig into the new SniperSnapshot object to make sure we’re matching the right one. We rewrite the expectation with a custom matcher that checks just the state:

公共类 AuctionSniperTest {

[...]



context.checking(new Expectations(){{

ignoring(auction);

允许(sniperListener).sniperStateChanged(

带有(aSniperThatIs(BIDDING)));

然后(sniperState.is(“出价”));



至少(1).of(sniperListener).sniperLost(); 当(sniperState.is(“出价”));

}});



[...]



private Matcher <SniperSnapshot> aSniperThatIs(final SniperState state){

返回新的FeatureMatcher <SniperSnapshot,SniperState>(

equalTo(state),“狙击手是”,“是”)

{

@Override

protected SniperState featureValueOf(SniperSnapshot actual){

返回实际.state;

} }

;

}

}

public class AuctionSniperTest {

[...]



context.checking(new Expectations() {{

ignoring(auction);

allowing(sniperListener).sniperStateChanged(

with(aSniperThatIs(BIDDING)));

then(sniperState.is("bidding"));



atLeast(1).of(sniperListener).sniperLost(); when(sniperState.is("bidding"));

}});



[...]



private Matcher<SniperSnapshot> aSniperThatIs(final SniperState state) {

return new FeatureMatcher<SniperSnapshot, SniperState>(

equalTo(state), "sniper that is ", "was")

{

@Override

protected SniperState featureValueOf(SniperSnapshot actual) {

return actual.state;

}

};

}

}

jMock 的轻量级扩展

Lightweight Extensions to jMock

图像

我们添加了一个小的辅助方法,用一个描述性名称aSniperThatIs()来包装我们对的专门化FeatureMatcher。您会发现,该方法名称旨在使期望代码可读性好(或者说,尽可能在 Java 中管理)。我们在本章前面对做了同样的事情aRowChangedEvent()。正如我们在第51页的“不同级别的语言”中讨论的那样,我们实际上是在为嵌入在 Java 中的语言编写扩展。jMock 设计为以这种方式可扩展,以便程序员可以插入根据他们正在测试的代码描述的功能。您可以将这些小辅助方法视为在 jMock 的期望语言中创建新的名词。

We added a small helper method aSniperThatIs() to package up our specialization of FeatureMatcher behind a descriptive name. You’ll see that the method name is intended to make the expectation code read well (or as well as we can manage in Java). We did the same earlier in the chapter with aRowChangedEvent(). As we discussed in “Different Levels of Language” on page 51, we’re effectively writing extensions to a language that’s embedded in Java. jMock was designed to be extensible in this way, so that programmers can plug in features described in terms of the code they’re testing. You could think of these little helper methods as creating new nouns in jMock’s expectation language.

填写数字

Filling In the Numbers

现在,我们可以将缺失的价格提供给用户界面,这意味着将侦听器调用从sniperWinning()更改为sniperStateChanged(),以便侦听器将接收 中的值SniperSnapshot。我们首先将测试更改为期望不同的侦听器调用,并通过调用两次来触发事件currentPrice():一次强制狙击手出价,另一次告诉狙击手它赢了。

Now we’re in a position to feed the missing price to the user interface, which means changing the listener call from sniperWinning() to sniperStateChanged() so that the listener will receive the value in a SniperSnapshot. We start by changing the test to expect the different listener call, and to trigger the event by calling currentPrice() twice: once to force the Sniper to bid, and again to tell the Sniper that it’s winning.

公共类 AuctionSniperTest { [...]

@Test public void

reportsIsWinningWhenCurrentPriceComesFromSniper() {

context.checking(new Expectations() {{

ignoring(auction);

允许(sniperListener).sniperStateChanged(

with(aSniperThatIs(BIDDING)));

然后(sniperState.is("bidding"));



至少(1).of(sniperListener).sniperStateChanged(

new SniperSnapshot(ITEM_ID, 135, 135, WINNING));

当(sniperState.is("bidding"));

}});



狙击手.currentPrice(123, 12, PriceSource.FromOtherBidder);

狙击手.currentPrice(135, 45, PriceSource.FromSniper);

}

}

public class AuctionSniperTest { [...]

@Test public void

reportsIsWinningWhenCurrentPriceComesFromSniper() {

context.checking(new Expectations() {{

ignoring(auction);

allowing(sniperListener).sniperStateChanged(

with(aSniperThatIs(BIDDING)));

then(sniperState.is("bidding"));



atLeast(1).of(sniperListener).sniperStateChanged(

new SniperSnapshot(ITEM_ID, 135, 135, WINNING));

when(sniperState.is("bidding"));

}});



sniper.currentPrice(123, 12, PriceSource.FromOtherBidder);

sniper.currentPrice(135, 45, PriceSource.FromSniper);

}

}

我们改为AuctionSniper通过保留最后一个快照来保留其最新值。我们还向添加了一些辅助方法SniperSnapshot,发现我们的实现开始变得简单。

We change AuctionSniper to retain its most recent values by holding on to the last snapshot. We also add some helper methods to SniperSnapshot, and find that our implementation starts to simplify.

公共类 AuctionSniper 实现 AuctionEventListener { [...]

私有 SniperSnapshot 快照;



公共 AuctionSniper(String itemId、Auction auction、SniperListener sniperListener)

{

this.auction = auction;

this.sniperListener = sniperListener;

this.snapshot = SniperSnapshot.joining(itemId);

}



公共 void currentPrice(int price、int increase、PriceSource priceSource){

isWinning = priceSource == PriceSource.FromSniper;

如果(isWinning){

snapshot = snap.winning(price);

} else {

final int bid = price + increase;

auction.bid(bid);

快照 = snap.bidding(price,bid);

}

sniperListener.sniperStateChanged(快照);

}

}



公共类 SniperSnapshot { [...]

公共 SniperSnapshot 出价(int newLastPrice,int newLastBid){

返回新的 SniperSnapshot(itemId,newLastPrice,newLastBid,SniperState.BIDDING);

}



公共 SniperSnapshot 获胜(int newLastPrice){

返回新的 SniperSnapshot(itemId,newLastPrice,lastBid,SniperState.WINNING);

}



公共静态 SniperSnapshot 加入(String itemId){

返回新的 SniperSnapshot(itemId,0,0,SniperState.JOINING);

}

}

public class AuctionSniper implements AuctionEventListener { [...]

private SniperSnapshot snapshot;



public AuctionSniper(String itemId, Auction auction, SniperListener sniperListener)

{

this.auction = auction;

this.sniperListener = sniperListener;

this.snapshot = SniperSnapshot.joining(itemId);

}



public void currentPrice(int price, int increment, PriceSource priceSource) {

isWinning = priceSource == PriceSource.FromSniper;

if (isWinning) {

snapshot = snapshot.winning(price);

} else {

final int bid = price + increment;

auction.bid(bid);

snapshot = snapshot.bidding(price, bid);

}

sniperListener.sniperStateChanged(snapshot);

}

}



public class SniperSnapshot { [...]

public SniperSnapshot bidding(int newLastPrice, int newLastBid) {

return new SniperSnapshot(itemId, newLastPrice, newLastBid, SniperState.BIDDING);

}



public SniperSnapshot winning(int newLastPrice) {

return new SniperSnapshot(itemId, newLastPrice, lastBid, SniperState.WINNING);

}



public static SniperSnapshot joining(String itemId) {

return new SniperSnapshot(itemId, 0, 0, SniperState.JOINING);

}

}

几乎是一个国家机器

Nearly a State Machine

图像

我们添加了一些构造函数方法,为SniperSnapshot在快照状态之间移动提供了一种清晰的机制。它不是一个完整的状态机,因为我们不只强制执行“合法”的转换,但它是一个提示,并且它很好地打包了字段的获取和设置。

We’ve added some constructor methods to SniperSnapshot that provide a clean mechanism for moving between snapshot states. It’s not a full state machine, in that we don’t enforce only “legal” transitions, but it’s a hint, and it nicely packages up the getting and setting of fields.

我们sniperWinning()SniperListener和其实现中删除,并为添加了获胜的值SnipersTableModel.STATUS_TEXT

We remove sniperWinning() from SniperListener and its implementations, and add a value for winning to SnipersTableModel.STATUS_TEXT.

现在,端到端测试已通过。

Now, the end-to-end test passes.

跟进

Follow Through

赢利和亏损的转换

Converting Won and Lost

SniperListener这是可行的,但在我们说完成之前,我们仍然需要转换两个通知方法:sniperWon()sniperLost()。再次,我们用替换它们sniperStateChanged(),并为添加两个新值SniperState

This works, but we still have two notification methods in SniperListener left to convert before we can say we’re done: sniperWon() and sniperLost(). Again, we replace these with sniperStateChanged() and add two new values to SniperState.

代入这些变化后,我们发现代码进一步简化。我们isWinning从狙击手中删除字段,并将一些决策移至SniperSnapshot,这将知道狙击手是赢还是输,以及SniperState

Plugging these changes in, we find that the code simplifies further. We drop the isWinning field from the Sniper and move some decision-making into SniperSnapshot, which will know whether the Sniper is winning or losing, and SniperState.

公共类 AuctionSniper 实现 AuctionEventListener { [...]

公共 void auctionClosed() {

快照 = 快照.closed();

通知更改();

}



公共 void currentPrice(int price, int increase, PriceSource priceSource) {

开关(priceSource) {

案例 FromSniper:

快照 = 快照.winning(price);

打破;

案例 FromOtherBidder:

int bid = price + 增量;

拍卖.bid(bid);

快照 = 快照.bidding(price, bid);

打破;

}

通知更改();

}



私人 void 通知更改() {

sniperListener.sniperStateChanged(快照);

}

}

public class AuctionSniper implements AuctionEventListener { [...]

public void auctionClosed() {

snapshot = snapshot.closed();

notifyChange();

}



public void currentPrice(int price, int increment, PriceSource priceSource) {

switch(priceSource) {

case FromSniper:

snapshot = snapshot.winning(price);

break;

case FromOtherBidder:

int bid = price + increment;

auction.bid(bid);

snapshot = snapshot.bidding(price, bid);

break;

}

notifyChange();

}



private void notifyChange() {

sniperListener.sniperStateChanged(snapshot);

}

}

我们得意地注意到,AuctionSniper不再指SniperState;它隐藏在 中SniperSnapshot

We note, with smug satisfaction, that AuctionSniper no longer refers to SniperState; it’s hidden in SniperSnapshot.

公共类 SniperSnapshot { [...]

public SniperSnapshot closed() {

return new SniperSnapshot(itemId, lastPrice, lastBid, state.whenAuctionClosed() );

}

}

public enum SniperState {

加入 {

@Override public SniperState whenAuctionClosed() { return LOST; }

},

竞标 {

@Override public SniperState whenAuctionClosed() { return LOST; }

},

获胜 {

@Override public SniperState whenAuctionClosed() { return WON; }

},

失败,

获胜;



public SniperState whenAuctionClosed() {

throw new Defect("拍卖已结束");

}

}

public class SniperSnapshot { [...]

public SniperSnapshot closed() {

return new SniperSnapshot(itemId, lastPrice, lastBid, state.whenAuctionClosed());

}

}

public enum SniperState {

JOINING {

@Override public SniperState whenAuctionClosed() { return LOST; }

},

BIDDING {

@Override public SniperState whenAuctionClosed() { return LOST; }

},

WINNING {

@Override public SniperState whenAuctionClosed() { return WON; }

},

LOST,

WON;



public SniperState whenAuctionClosed() {

throw new Defect("Auction is already closed");

}

}

我们本来希望使用字段来实现whenAuctionClosed()。但事实证明,编译器无法处理enum对其尚未定义的值的引用,因此我们不得不忍受重写方法的语法噪音。

We would have preferred to use a field to implement whenAuctionClosed(). It turns out that the compiler cannot handle an enum referring to one of its values which has not yet been defined, so we have to put up with the syntax noise of overridden methods.

不太小,无法测试

Not Too Small to Test

图像

乍一看SniperState,它太简单了,无法进行单元测试——毕竟,它是通过AuctionSniper测试来执行的——但我们认为我们应该保持诚实。编写测试表明,我们的简单实现无法处理重新关闭拍卖的情况,这不应该发生,所以我们添加了一个例外。最好编写代码以使这种情况不可能发生,但我们现在不知道该怎么做。

At first SniperState looked too simple to unit-test—after all, it’s exercised through the AuctionSniper tests—but we thought we should keep ourselves honest. Writing the test showed that our simple implementation didn’t handle re-closing an auction, which shouldn’t happen, so we added an exception. It would be better to write the code so that this case is impossible, but we can’t see how to do that right now.

缺陷异常

A Defect Exception

图像

在我们构建的大多数系统中,我们最终都会编写一个名为Defect(或可能StupidProgrammerMistakeException)的运行时异常。当代码达到只能由编程错误而不是运行时环境故障导致的条件时,我们会抛出此异常。

In most systems we build, we end up writing a runtime exception called something like Defect (or perhaps StupidProgrammerMistakeException). We throw this when the code reaches a condition that could only be caused by a programming error, rather than a failure in the runtime environment.

修剪表格模型

Trimming the Table Model

setStatusText()我们删除了在 中设置状态显示字符串的访问器SnipersTableModel,因为sniperStatusChanged()现在一切都在使用。在此过程中,我们将狙击手状态的描述字符串常量从 移过来MainWindow

We remove the accessor setStatusText() that sets the state display string in SnipersTableModel, as everything uses sniperStatusChanged() now. While we’re at it, we move the description string constants for the Sniper state over from MainWindow.

public class SnipersTableModel extends AbstractTableModel { [...]

private final static String[] STATUS_TEXT = {

"加入", "投标", "获胜", "失败", "赢了"

};



public Object getValueAt(int rowIndex, int columnIndex) {

switch (Column.at(columnIndex)) {

case ITEM_IDENTIFIER:

return snap.itemId;

case LAST_PRICE:

return snap.lastPrice;

case LAST_BID:

return snap.lastBid;

case SNIPER_STATE:

return textFor(snapshot.state);

default:

throw new IllegalArgumentException("没有列在" + columnIndex);

}

}



public void sniperStateChanged(SniperSnapshot newSnapshot) {

this.snapshot = newSnapshot;

fireTableRowsUpdated(0, 0);

}



公共静态字符串文本(SniperState状态){

返回STATUS_TEXT [state.ordinal()];

}

}

public class SnipersTableModel extends AbstractTableModel { [...]

private final static String[] STATUS_TEXT = {

"Joining", "Bidding", "Winning", "Lost", "Won"

};



public Object getValueAt(int rowIndex, int columnIndex) {

switch (Column.at(columnIndex)) {

case ITEM_IDENTIFIER:

return snapshot.itemId;

case LAST_PRICE:

return snapshot.lastPrice;

case LAST_BID:

return snapshot.lastBid;

case SNIPER_STATE:

return textFor(snapshot.state);

default:

throw new IllegalArgumentException("No column at" + columnIndex);

}

}



public void sniperStateChanged(SniperSnapshot newSnapshot) {

this.snapshot = newSnapshot;

fireTableRowsUpdated(0, 0);

}



public static String textFor(SniperState state) {

return STATUS_TEXT[state.ordinal()];

}

}

辅助方法textFor()有助于提高可读性,并且我们还使用它来获取测试中的显示字符串,因为常量不再从​​ 访问MainWindow

The helper method, textFor(), helps with readability, and we also use it to get hold of the display strings in tests since the constants are no longer accessible from MainWindow.

面向对象列

Object-Oriented Column

在完成这项任务之前,我们还有几件事要做。我们首先删除所有未指定价格详细信息的旧测试代码,并根据需要在测试中填写预期值。测试仍可运行。

We still have a couple of things to do before we finish this task. We start by removing all the old test code that didn’t specify the price details, filling in the expected values in the tests as required. The tests still run.

下一个更改是替换switch嘈杂、不太面向对象且包含不必要的default:子句的语句,只是为了满足编译器。它已经达到了目的,即帮助我们完成之前的编码阶段。我们添加一个方法来Column提取适当的字段:

The next change is to replace the switch statement which is noisy, not very object-oriented, and includes an unnecessary default: clause just to satisfy the compiler. It’s served its purpose, which was to get us through the previous coding stage. We add a method to Column that will extract the appropriate field:

公共枚举列 {

ITEM_IDENTIFIER {

@Override 公共对象值(SniperSnapshot 快照) {

返回快照。itemId;

}

},



LAST_PRICE {

@Override 公共对象值(SniperSnapshot 快照) {

返回快照。lastPrice;

}

},



LAST_BID{

@Override 公共对象值(SniperSnapshot 快照) {

返回快照。lastBid;

}

},



SNIPER_STATE {

@Override 公共对象值(SniperSnapshot 快照) {

返回 SnipersTableModel.textFor(snapshot.state);

}

};



抽象公共对象值(SniperSnapshot 快照);

[...]

}

public enum Column {

ITEM_IDENTIFIER {

@Override public Object valueIn(SniperSnapshot snapshot) {

return snapshot.itemId;

}

},



LAST_PRICE {

@Override public Object valueIn(SniperSnapshot snapshot) {

return snapshot.lastPrice;

}

},



LAST_BID{

@Override public Object valueIn(SniperSnapshot snapshot) {

return snapshot.lastBid;

}

},



SNIPER_STATE {

@Override public Object valueIn(SniperSnapshot snapshot) {

return SnipersTableModel.textFor(snapshot.state);

}

};



abstract public Object valueIn(SniperSnapshot snapshot);

[...]

}

并且中的代码SnipersTableModel变得可以忽略不计:

and the code in SnipersTableModel becomes negligible:

公共类 SnipersTableModel 扩展了 AbstractTableModel { [...]

公共对象 getValueAt(int rowIndex, int columnIndex) {

返回Column.at(columnIndex).valueIn(snapshot);

}

}

public class SnipersTableModel extends AbstractTableModel { [...]

public Object getValueAt(int rowIndex, int columnIndex) {

return Column.at(columnIndex).valueIn(snapshot);

}

}

当然,我们为编写了一个单元测试Column。现在看来这似乎没有必要,但当我们进行更改并忘记保持列映射为最新时,它将保护我们。

Of course, we write a unit test for Column. It may seem unnecessary now, but it will protect us when we make changes and forget to keep the column mapping up to date.

缩短事件路径

Shortening the Event Path

最后,我们发现有一些不再需要的转接电话。MainWindow只是转发更新,并且SniperStateDisplayer已经崩溃到几乎没有了。

Finally, we see that we have some forwarding calls that we no longer need. MainWindow just forwards the update and SniperStateDisplayer has collapsed to almost nothing.

公共类 MainWindow 扩展了 JFrame { [...]

公共 void sniperStateChanged(SniperSnapshot 快照) {

snipers.sniperStateChanged(快照);

}

}

公共类 SniperStateDisplayer 实现 SniperListener { [...]

公共 void sniperStateChanged(最终 SniperSnapshot 快照) {

SwingUtilities.invokeLater(新 Runnable() {

公共 void run() { mainWindow.sniperStateChanged(快照); }

});

}

}

public class MainWindow extends JFrame { [...]

public void sniperStateChanged(SniperSnapshot snapshot) {

snipers.sniperStateChanged(snapshot);

}

}

public class SniperStateDisplayer implements SniperListener { [...]

public void sniperStateChanged(final SniperSnapshot snapshot) {

SwingUtilities.invokeLater(new Runnable() {

public void run() { mainWindow.sniperStateChanged(snapshot); }

});

}

}

SniperStateDisplayer仍然具有一个有用的用途,即将更新推送到 Swing 事件线程,但它不再在代码中的域之间进行任何转换,并且对的调用是不必要的。我们决定通过实现MainWindow来简化连接。我们将 改为Decorator并将其重命名为,然后我们重新连接,以便 Sniper 连接到表模型而不是窗口。SnipersTableModelSniperListenerSniperStateDisplayerSwingThreadSniperListenerMain

SniperStateDisplayer still serves a useful purpose, which is to push updates onto the Swing event thread, but it no longer does any translation between domains in the code, and the call to MainWindow is unnecessary. We decide to simplify the connections by making SnipersTableModel implement SniperListener. We change SniperStateDisplayer to be a Decorator and rename it to SwingThreadSniperListener, and we rewire Main so that the Sniper connects to the table model rather than the window.

公共类 Main { [...]

私有最终 SnipersTableModel snipers = new SnipersTableModel();

私有 MainWindow ui;



公共 Main() 抛出异常 {

SwingUtilities.invokeAndWait(new Runnable() {

公共 void run() { ui = new MainWindow( snipers ); }

});

}



私有 void joinAuction(XMPPConnection connection,String itemId) {

[...]

Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

auction.join();

}

}

public class Main { [...]

private final SnipersTableModel snipers = new SnipersTableModel();

private MainWindow ui;



public Main() throws Exception {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() { ui = new MainWindow(snipers); }

});

}



private void joinAuction(XMPPConnection connection, String itemId) {

[...]

Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

auction.join();

}

}

新的结构如图15.4所示。

The new structure looks like Figure 15.4.

TableModel 15.4 SniperListener

Figure 15.4 TableModel as a SniperListener

图像

最终润色

Final Polish

列标题测试

A Test for Column Titles

为了使用户界面美观,我们需要填写列标题,如图 15.3所示,这些标题仍然缺失。这并不难,因为大多数实现都内置在 Swing 中TableModel。与往常一样,我们从验收测试开始。我们添加了额外的验证,AuctionSniperDriver将由启动 Sniper 的方法调用ApplicationRunner。为了保险起见,我们检查了应用程序的显示标题。

To make the user interface presentable, we need to fill in the column titles which, as we saw in Figure 15.3, are still missing. This isn’t difficult, since most of the implementation is built into Swing’s TableModel. As always, we start with the acceptance test. We add extra validation to AuctionSniperDriver that will be called by the method in ApplicationRunner that starts up the Sniper. For good measure, we throw in a check for the application’s displayed title.

公共类 ApplicationRunner { [...]

公共 void startBiddingIn(final FakeAuctionServer auction) {

itemId = auction.getItemId();



Thread thread = new Thread(“测试应用程序”){

[...]

};

thread.setDaemon(true);

thread.start();



driver = new AuctionSniperDriver(1000);

driver.hasTitle(MainWindow.APPLICATION_TITLE);

driver.hasColumnTitles();

driver.showsSniperStatus(JOINING.itemId,JOINING.lastPrice,

JOINING.lastBid,textFor(SniperState.JOINING));

}

}



公共类 AuctionSniperDriver 扩展了 JFrameDriver { [...]

公共 void hasColumnTitles(){

JTableHeaderDriver headers = new JTableHeaderDriver(this,JTableHeader.class);

headers.hasHeaders(matching(withLabelText("商品"), withLabelText("最新价格"),

withLabelText("最新出价"), withLabelText("州")));

}

}

public class ApplicationRunner { [...]

public void startBiddingIn(final FakeAuctionServer auction) {

itemId = auction.getItemId();



Thread thread = new Thread("Test Application") {

[...]

};

thread.setDaemon(true);

thread.start();



driver = new AuctionSniperDriver(1000);

driver.hasTitle(MainWindow.APPLICATION_TITLE);

driver.hasColumnTitles();

driver.showsSniperStatus(JOINING.itemId, JOINING.lastPrice,

JOINING.lastBid, textFor(SniperState.JOINING));

}

}



public class AuctionSniperDriver extends JFrameDriver { [...]

public void hasColumnTitles() {

JTableHeaderDriver headers = new JTableHeaderDriver(this, JTableHeader.class);

headers.hasHeaders(matching(withLabelText("Item"), withLabelText("Last Price"),

withLabelText("Last Bid"), withLabelText("State")));

}

}

测试失败:

The test fails:

java.lang.AssertionError:试图 在所有顶层窗口中

查找...恰好 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上)中的

1 个 JTableHeader(),并检查它是否带有带有单元格 <label with text "Item">、<label with text "Last Price">、 <label with text "Last Bid">、<label with text "State"> 的标题,但是... 所有顶层窗口都 包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上), 其中包含 1 个 JTableHeader(), 它没有带有单元格 <label with text "Item">、<label with text "Last Price">、 <label with text "Last Bid">、<label with text "State"> 的标题,因为组件 0 的文本为“A”

























java.lang.AssertionError:

Tried to look for...

exactly 1 JTableHeader ()

in exactly 1 JFrame (with name "Auction Sniper Main" and showing on screen)

in all top level windows

and check that it is with headers with cells

<label with text "Item">, <label with text "Last Price">,

<label with text "Last Bid">, <label with text "State">

but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JTableHeader ()

it is not with headers with cells

<label with text "Item">, <label with text "Last Price">,

<label with text "Last Bid">, <label with text "State">

because component 0 text was "A"

实现 TableModel

Implementing the TableModel

Swing 允许JTable查询其TableModel列标题,这是我们选择使用的机制。我们已经必须Column表示列,因此我们enum通过添加一个用于标题文本的字段来扩展它,我们在 中引用该字段SnipersTableModel

Swing allows a JTable to query its TableModel for the column headers, which is the mechanism we’ve chosen to use. We already have Column to represent the columns, so we extend this enum by adding a field for the header text which we reference in SnipersTableModel.

公共枚举列 {

ITEM_IDENTIFIER( "项目" ) { [...]

LAST_PRICE( "最后价格" ) { [...]

LAST_BID( "最后出价" ) { [...]

SNIPER_STATE( "状态" ) { [...]

public final String name;



private Column(String name) {

this.name = name;

}

}

public class SnipersTableModel extends AbstractTableModel implements SniperListener

{ [...]

@Override public String getColumnName(int column) {

return Column.at(column). name;

}

}

public enum Column {

ITEM_IDENTIFIER("Item") { [...]

LAST_PRICE("Last Price") { [...]

LAST_BID("Last Bid") { [...]

SNIPER_STATE("State") { [...]

public final String name;



private Column(String name) {

this.name = name;

}

}

public class SnipersTableModel extends AbstractTableModel implements SniperListener

{ [...]

@Override public String getColumnName(int column) {

return Column.at(column).name;

}

}

我们真正需要在单元测试中检查的SniperTablesModel是值和列名之间的链接Column,但迭代非常简单,我们检查它们全部:

All we really need to check in the unit test for SniperTablesModel is the link between a Column value and a column name, but it’s so simple to iterate that we check them all:

公共类 SnipersTableModelTest { [...]

@Test public void

setsUpColumnHeadings() {

for (Column column:Column.values()) {

assertEquals(column.name,model.getColumnName(column.ordinal()));

}

}

}

public class SnipersTableModelTest { [...]

@Test public void

setsUpColumnHeadings() {

for (Column column: Column.values()) {

assertEquals(column.name, model.getColumnName(column.ordinal()));

}

}

}

验收测试通过,我们可以在图 15.5中看到结果。

The acceptance test passes, and we can see the result in Figure 15.5.

图 15.5 带有列标题的狙击手

Figure 15.5 Sniper with column headers

图像

现在够了

Enough for Now

我们还应该做更多的事情,比如设置边框和文本对齐,以调整用户界面。我们可以通过为CellRenderer每个值关联 s来实现这Column一点,或者通过引入 来实现这一点TableColumnModel。我们将这些留给读者作为练习,因为它们不会为我们的开发过程增加任何见解。

There’s more we should do, such as set up borders and text alignment, to tune the user interface. We might do that by associating CellRenderers with each Column value, or perhaps by introducing a TableColumnModel. We’ll leave those as an exercise for the reader, since they don’t add any more insight into our development process.

与此同时,我们可以从待办事项清单中划掉一项任务:图 15.6

In the meantime, we can cross off one more task from our to-do list: Figure 15.6.

图 15.6 狙击手显示价格信息

Figure 15.6 The Sniper shows price information

图像

观察结果

Observations

单一职责

Single Responsibilities

SnipersTableModel有一个职责:在用户界面中表示我们的出价状态。它遵循我们在“没有 And、Or 或 But ”中描述的启发式方法第 51页)。我们已经看到太多用户界面代码因为混入了业务逻辑而变得脆弱。在这种情况下,我们也可以让模型负责决定是否出价(“因为这样更简单”),但这会使用户界面或出价策略发生变化时更难做出响应。甚至很难找到出价策略,这就是我们在 中将其隔离的原因AuctionSniper

SnipersTableModel has one responsibility: to represent the state of our bidding in the user interface. It follows the heuristic we described in “No And’s, Or’s, or But’s(page 51). We’ve seen too much user interface code that is brittle because it has business logic mixed in. In this case, we could also have made the model responsible for deciding whether to bid (“because that would be simpler”), but that would make it harder to respond when either the user interface or the bidding policy change. It would be harder to even find the bidding policy, which is why we isolated it in AuctionSniper.

软件的锁孔手术

Keyhole Surgery for Software

在本章中,我们反复使用了在整个系统中添加小片段行为的做法:用表格替换标签,使其工作;显示狙击手出价,使其工作;添加其他值,使其工作。在所有这些情况下,我们都已经弄清楚了我们想要达到的目标(始终允许我们在此过程中发现更好的替代方案),但我们希望避免为了达到目标而将应用程序拆开。一旦我们开始进行重大返工,我们就不能停下来,直到它完成,我们不能在没有分支的情况下签入,并且与团队其他成员合并更加困难。外科医生更喜欢锁孔手术而不是开刀手术是有原因的——它创伤性更小,更便宜。

In this chapter we repeatedly used the practice of adding little slices of behavior all the way through the system: replace a label with a table, get that working; show the Sniper bidding, get that working; add the other values, get that working. In all of these cases, we’ve figured out where we want to get to (always allowing that we might discover a better alternative along the way), but we want to avoid ripping the application apart to get there. Once we start a major rework, we can’t stop until it’s finished, we can’t check in without branching, and merging with rest of the team is harder. There’s a reason that surgeons prefer keyhole surgery to opening up a patient—it’s less invasive and cheaper.

程序员的超敏感性

Programmer Hyper-Sensitivity

我们对自己时间的价值有着敏锐的感知。我们会留意那些似乎没有充分发挥我们(无疑非常重要的)才能的活动,例如样板复制和改编代码:如果我们有正确的抽象,我们就不必费心了。有时这是必须做的,特别是在处理现有代码时——但当我们自己的代码时,借口就少了。决定何时更改设计需要良好的权衡意识,这意味着敏感性和技术成熟度:“我即将重复这段代码,但略作改动,这似乎很枯燥和浪费”,而不是“现在可能不是重新编写这段代码的正确时机,我还不理解它。”

We have a well-developed sense of the value of our own time. We keep an eye out for activities that don’t seem to be making the best of our (doubtless significant) talents, such as boiler-plate copying and adapting code: if we had the right abstraction, we wouldn’t have to bother. Sometimes this just has to be done, especially when working with existing code—but there are fewer excuses when it’s our own. Deciding when to change the design requires a good sense for tradeoffs, which implies both sensitivity and technical maturity: “I’m about to repeat this code with minor variations, that seems dull and wasteful” as against “This may not be the right time to rework this, I don’t understand it yet.”

我们这里没有简单、可复制的技术;它需要技巧和经验。开发人员应该养成反思自己活动的习惯,思考如何最好地投入时间完成剩下的编码工作。这可能意味着要像以前一样继续工作,但至少他们会考虑这一点。

We don’t have a simple, reproducible technique here; it requires skill and experience. Developers should have a habit of reflecting on their activity, on the best way to invest their time for the rest of a coding session. This might mean carrying on exactly as before, but at least they’ll have thought about it.

庆祝改变主意

Celebrate Changing Your Mind

当事实改变时,我就会改变主意。先生,您怎么做?

When the facts change, I change my mind. What do you do, sir?

—约翰·梅纳德·凯恩斯

—John Maynard Keynes

在本章中,我们重命名了代码中的几个功能。在许多开发文化中,这被视为软弱的表现,是无法做好工作的表现。相反,我们认为这是我们开发过程的重要组成部分。只是随着我们通过使用自己编写的代码更多地了解结构应该是什么样子,我们在使用它们时会更多地了解我们选择的名称。我们了解类型和方法名称如何组合在一起,以及概念是否清晰,这刺激了新想法的发现。如果某个功能的名称不正确,唯一明智的做法就是更改它,并避免以后阅读代码的所有人花费无数时间感到困惑。

During this chapter, we renamed several features in the code. In many development cultures, this is viewed as a sign of weakness, as an inability to do a proper job. Instead, we think this is an essential part of our development process. Just as we learn more about what the structure should be by using the code we’ve written, we learn more about the names we’ve chosen when we work with them. We see how the type and method names fit together and whether the concepts are clear, which stimulates the discovery of new ideas. If the name of a feature isn’t right, the only smart thing to do is change it and avoid countless hours of confusion for all who will read the code later.

这不是唯一的解决方案

This Isn’t the Only Solution

书中的例子,比如这个,读起来似乎好像解决方案是必然的。这部分是因为我们努力让叙述流畅,但也是因为提出一个解决方案往往会让读者忘记其他解决方案。我们本可以考虑其他变化,其中一些甚至可能随着例子的发展而重新浮现。

Examples in books, such as this one, tend to read as if there was an inevitability about the solution. That’s partly because we put effort into making the narrative flow, but it’s also because presenting one solution tends to drive others out of the reader’s consciousness. There are other variations we could have considered, some of which might even resurface as the example develops.

例如,我们可以说,它AuctionSniper不需要知道它是赢得还是输掉了拍卖——只需要知道它是否应该出价。目前,应用程序中唯一关心获胜的部分是用户界面,如果我们把这个决定权从他们手中移开,肯定会简化流程AuctionSniperSniperSnapshot我们现在不会这么做,因为我们还不知道这是否是正确的选择,但我们发现,反复考虑设计方案有时会带来更好的解决方案。

For example, we could argue that AuctionSniper doesn’t need to know whether it’s won or lost the auction—just whether it should bid or not. At present, the only part of the application that cares about winning is the user interface, and it would certainly simplify the AuctionSniper and SniperSnapshot if we moved that decision away from them. We won’t do that now, because we don’t yet know if it’s the right choice, but we find that kicking around design options sometimes leads to much better solutions.

第 16 章 狙击多个物品

Chapter 16. Sniping for Multiple Items

我们对多个项目进行竞标,将每个连接的代码与每个拍卖的代码分开。我们使用刚刚介绍的表格模型来显示额外的出价。我们扩展了用户界面,以允许用户动态添加项目。我们很高兴地发现,我们不必更改测试,只需更改它们的实现。我们梳理出了一个“用户请求监听器”概念,这意味着我们可以更直接地测试一些功能。我们让代码变得有点混乱。

In which we bid for multiple items, splitting the per-connection code from the per-auction code. We use the table model we just introduced to display the additional bids. We extend the user interface to allow users to add items dynamically. We’re pleased to find that we don’t have to change the tests, just their implementation. We tease out a “user request listener” concept, which means we can test some features more directly. We leave the code in a bit of a mess.

测试多个项目

Testing for Multiple Items

两件物品的故事

A Tale of Two Items

我们待办事项清单上的下一个任务是能够同时狙击多个物品。由于我们的用户界面基于表格,因此我们已经拥有了所需的大部分机制,因此只需进行一些细微的结构更改即可实现此功能。展望列表中的未来,我们可以将这一变化与通过用户界面添加物品结合起来,但我们认为目前还不需要这样做。只需专注于这一项任务,我们就可以明确区分属于狙击手与拍卖行连接的功能和属于单个拍卖的功能。到目前为止,我们已经在命令行上指定了物品,但我们可以扩展它以在参数列表中传递多个物品。

The next task on our to-do list is to be able to snipe for multiple items at the same time. We already have much of the machinery we’ll need in place, since our user interface is based on a table, so some minor structural changes are all we need to make this work. Looking ahead in the list, we could combine this change with adding items through the user interface, but we don’t think we need to do that yet. Just focusing on this one task means we can clarify the distinction between those features that belong to the Sniper’s connection to the auction house, and those that belong to an individual auction. So far we’ve specified the item on the command line, but we can extend that to pass multiple items in the argument list.

与往常一样,我们先进行测试。我们希望新测试能够显示应用程序可以竞标并赢得两个不同的物品,因此我们首先查看已有的测试。在“首先,失败的测试”(第152页)中,我们当前针对成功竞标的测试假设应用程序只有一个拍卖 — 它隐含在以下代码中:

As always, we start with a test. We want our new test to show that the application can bid for and win two different items, so we start by looking at the tests we already have. Our current test for a successful bid, in “First, a Failing Test” (page 152), assumes that the application has only one auction—it’s implicit in code such as:

application.hasShownSniperIsBidding(1000, 1098);

application.hasShownSniperIsBidding(1000, 1098);

我们通过将拍卖传递到每个ApplicationRunner调用来准备多个项目,因此代码现在如下所示:

We prepare for multiple items by passing an auction into each of the ApplicationRunner calls, so the code now looks like:

应用程序.hasShownSniperIsBidding(拍卖,1000,1098);

application.hasShownSniperIsBidding(auction, 1000, 1098);

在中ApplicationRunner,我们删除了该itemId字段,而是从auction参数中提取项目标识符。

Within the ApplicationRunner, we remove the itemId field and instead extract the item identifier from the auction parameters.

公共 void hasShownSniperIsBidding(FakeAuctionServer拍卖,

int lastPrice,int lastBid)

{

driver.showsSniperStatus(拍卖.getItemId(),lastPrice,lastBid,

textFor(SniperState.BIDDING));

}

public void hasShownSniperIsBidding(FakeAuctionServer auction,

int lastPrice, int lastBid)

{

driver.showsSniperStatus(auction.getItemId(), lastPrice, lastBid,

textFor(SniperState.BIDDING));

}

其余部分类似,这意味着我们可以编写一个新测试:

The rest is similar, which means we can write a new test:

公共类 AuctionSniperEndToEndTest {

私有最终 FakeAuctionServer 拍卖 = 新的 FakeAuctionServer("item-54321");

私有最终 FakeAuctionServer 拍卖2 = 新的 FakeAuctionServer("item-65432");



@Test public void

sniperBidsForMultipleItems() 抛出异常 {

拍卖.startSellingItem();

拍卖2.startSellingItem();



应用程序.startBiddingIn(拍卖,拍卖2 );

拍卖.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);拍卖

2.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



拍卖.reportPrice(1000, 98, "其他竞标者");

拍卖.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



拍卖2.reportPrice(500, 21, "其他竞标者");

拍卖2.hasReceivedBid(521, ApplicationRunner.SNIPER_XMPP_ID);



拍卖.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);

拍卖2.reportPrice(521, 22, ApplicationRunner.SNIPER_XMPP_ID);



应用程序.hasShownSniperIsWinning(拍卖, 1098);

应用程序.hasShownSniperIsWinning(拍卖2, 521);



拍卖.announceClosed();

拍卖2.announceClosed();



应用程序.showsSniperHasWonAuction(拍卖, 1098);

应用程序.showsSniperHasWonAuction(拍卖2, 521);

}

}

public class AuctionSniperEndToEndTest {

private final FakeAuctionServer auction = new FakeAuctionServer("item-54321");

private final FakeAuctionServer auction2 = new FakeAuctionServer("item-65432");



@Test public void

sniperBidsForMultipleItems() throws Exception {

auction.startSellingItem();

auction2.startSellingItem();



application.startBiddingIn(auction, auction2);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);

auction2.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1000, 98, "other bidder");

auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction2.reportPrice(500, 21, "other bidder");

auction2.hasReceivedBid(521, ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);

auction2.reportPrice(521, 22, ApplicationRunner.SNIPER_XMPP_ID);



application.hasShownSniperIsWinning(auction, 1098);

application.hasShownSniperIsWinning(auction2, 521);



auction.announceClosed();

auction2.announceClosed();



application.showsSniperHasWonAuction(auction, 1098);

application.showsSniperHasWonAuction(auction2, 521);

}

}

auction-item-65432按照协议惯例,我们还记得向聊天服务器添加一个新用户来代表新的拍卖。

Following the protocol convention, we also remember to add a new user, auction-item-65432, to the chat server to represent the new auction.

避免误报

Avoiding False Positives

图像

我们将这些showsSniper方法组合在一起,而不是将它们与相关的拍卖触发器配对。这是为了解决我们在早期版本中发现的一个问题,即每个检查方法都会获取最近的更改 - 即我们在上一次调用中刚刚触发的更改。将检查方法组合在一起让我们确信它们同时有效。

We group the showsSniper methods together instead of pairing them with their associated auction triggers. This is to catch a problem that we found in an earlier version where each checking method would pick up the most recent change—the one we’d just triggered in the previous call. Grouping the checking methods together gives us confidence that they’re both valid at the same time.

应用程序运行器

The ApplicationRunner

ApplicationRunner我们必须对方法进行的一个重大更改startBiddingIn()。现在它需要接受传递到狙击手命令行的可变数量的拍卖。转换有点混乱,因为我们必须解压项目标识符并将它们附加到其他命令行参数的末尾——这是我们用 Java 数组能做的最好的事情:

The one significant change we have to make in the ApplicationRunner is to the startBiddingIn() method. Now it needs to accept a variable number of auctions passed through to the Sniper’s command line. The conversion is a bit messy since we have to unpack the item identifiers and append them to the end of the other command-line arguments—this is the best we can do with Java arrays:

公共类 ApplicationRunner { [...] s

public void startBiddingIn(final FakeAuctionServer...auctions ) {

Thread thread = new Thread("测试应用程序") {

@Override public void run() {

try {

Main.main( arguments(auctions) );

} catch (Throwable e) {

[...]

for (FakeAuctionServer auction: auctions) {

driver.showsSniperStatus(auction.getItemId(), 0, 0, textFor(JOINING));

}

}



protected static String[]arguments(FakeAuctionServer...auctions) {

String[]arguments = new String[auctions.length + 3];

arguments[0] = XMPP_HOSTNAME;

arguments[1] = SNIPER_ID;

arguments[2] = SNIPER_PASSWORD;

for (int i = 0;i <auctions.length;i++) {

arguments[i + 3] = auctions[i].getItemId();

}

返回参数;

}

}

public class ApplicationRunner { [...]s

public void startBiddingIn(final FakeAuctionServer... auctions) {

Thread thread = new Thread("Test Application") {

@Override public void run() {

try {

Main.main(arguments(auctions));

} catch (Throwable e) {

[...]

for (FakeAuctionServer auction : auctions) {

driver.showsSniperStatus(auction.getItemId(), 0, 0, textFor(JOINING));

}

}



protected static String[] arguments(FakeAuctionServer... auctions) {

String[] arguments = new String[auctions.length + 3];

arguments[0] = XMPP_HOSTNAME;

arguments[1] = SNIPER_ID;

arguments[2] = SNIPER_PASSWORD;

for (int i = 0; i < auctions.length; i++) {

arguments[i + 3] = auctions[i].getItemId();

}

return arguments;

}

}

我们运行测试并发现它失败了。

We run the test and watch it fail.

java.lang.AssertionError:

预期:不为空, 在 auctionsniper.SingleMessageListener.receivesAMessage()

处得到:为空

java.lang.AssertionError:

Expected: is not null

got: null

at auctionsniper.SingleMessageListener.receivesAMessage()

转移话题,修复失败信息

A Diversion, Fixing the Failure Message

我们第一次看到这个神秘的失败信息是在第 11 章。当时它还不算太糟,因为它只会在一个地方发生,而且反正也没有太多代码需要测试。现在它更烦人了,因为我们必须找到这个方法:

We first saw this cryptic failure message in Chapter 11. It wasn’t so bad then because it could only occur in one place and there wasn’t much code to test anyway. Now it’s more annoying because we have to find this method:

public void receivedAMessage(Matcher<? super String> messageMatcher)

throws InterruptedException

{

final Message message = messages.poll(5, TimeUnit.SECONDS);

assertThat(message, is(notNullValue()));

assertThat(message.getBody(), messageMatcher);

}

public void receivesAMessage(Matcher<? super String> messageMatcher)

throws InterruptedException

{

final Message message = messages.poll(5, TimeUnit.SECONDS);

assertThat(message, is(notNullValue()));

assertThat(message.getBody(), messageMatcher);

}

并找出我们遗漏了什么。我们希望将这两个断言结合起来,并提供更有意义的失败。我们可以为消息正文编写一个自定义匹配器,但考虑到的结构Message不会很快改变,我们可以使用PropertyMatcher,如下所示:

and figure out what we’re missing. We’d like to combine these two assertions and provide a more meaningful failure. We could write a custom matcher for the message body but, given that the structure of Message is not going to change soon, we can use a PropertyMatcher, like this:

public void receivedAMessage(Matcher<? super String> messageMatcher)

throws InterruptedException

{

final Message message = messages.poll(5, TimeUnit.SECONDS);

assertThat(message, hasProperty("body", messageMatcher));

}

public void receivesAMessage(Matcher<? super String> messageMatcher)

throws InterruptedException

{

final Message message = messages.poll(5, TimeUnit.SECONDS);

assertThat(message, hasProperty("body", messageMatcher));

}

这将生成更有用的故障报告:

which produces this more helpful failure report:

java.lang.AssertionError:

预期:hasProperty(“body”,“SOLVersion:1.1;命令:JOIN;”)

得到:null

java.lang.AssertionError:

Expected: hasProperty("body", "SOLVersion: 1.1; Command: JOIN;")

got: null

再多花点功夫,我们就可以扩展一个,FeatureMatcher提取消息正文,并提供更好的失败报告。区别不大,只是需要进行静态类型检查。现在回到正题上。

With slightly more effort, we could have extended a FeatureMatcher to extract the message body with a nicer failure report. There’s not much difference, expect that it would be statically type-checked. Now back to business.

重组主要

Restructuring Main

测试失败,因为狙击手没有发送Join第二次拍卖的消息。我们必须更改Main以解释附加参数。提醒一下,代码的当前结构是:

The test is failing because the Sniper is not sending a Join message for the second auction. We must change Main to interpret the additional arguments. Just to remind you, the current structure of the code is:

公共类 Main {

public Main() 抛出异常 {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() {

ui = new MainWindow(snipers);

}

});

}



公共静态 void main(String... args) 抛出异常 {

Main main = new Main();

main.joinAuction(

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]),

args[ARG_ITEM_ID]);

}



private void joinAuction(XMPPConnection connection, String itemId) {

disconnectWhenUICloses(connection);

聊天 chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

[...]

}

}

public class Main {

public Main() throws Exception {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() {

ui = new MainWindow(snipers);

}

});

}



public static void main(String... args) throws Exception {

Main main = new Main();

main.joinAuction(

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]),

args[ARG_ITEM_ID]);

}



private void joinAuction(XMPPConnection connection, String itemId) {

disconnectWhenUICloses(connection);

Chat chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

[...]

}

}

要添加多个项目,我们需要区分与拍卖服务器建立连接的代码和加入拍卖的代码。我们首先保留它,connection以便我们可以在多个聊天中重复使用它;结果不是很面向对象,但我们希望等待并观察结构如何发展。我们还将notToBeGCd单个值更改为集合。

To add multiple items, we need to distinguish between the code that establishes a connection to the auction server and the code that joins an auction. We start by holding on to connection so we can reuse it with multiple chats; the result is not very object-oriented but we want to wait and see how the structure develops. We also change notToBeGCd from a single value to a collection.

公共类 Main {

公共静态 void main(String...args) 抛出异常 {

Main main = new Main();

XMPPConnection connection =

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(connection);

main.joinAuction(connection, args[ARG_ITEM_ID]);

}

private void joinAuction(XMPPConnection connection, String itemId) {

聊天 chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

notToBeGCd.add(chat);



拍卖 auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

拍卖.join();

}

}

public class Main {

public static void main(String... args) throws Exception {

Main main = new Main();

XMPPConnection connection =

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(connection);

main.joinAuction(connection, args[ARG_ITEM_ID]);

}

private void joinAuction(XMPPConnection connection, String itemId) {

Chat chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

notToBeGCd.add(chat);



Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

auction.join();

}

}

我们循环遍历所给出的每个项目:

We loop through each of the items that we’ve been given:

公共静态void main(String ... args)抛出异常{

Main main = new Main();

XMPPConnection connection =

connection(args [ARG_HOSTNAME],args [ARG_USERNAME],args [ARG_PASSWORD]);

main.disconnectWhenUICloses(connection);



for(int i = 3; i <args.length; i ++){

main.joinAuction(connection,args [i]);

}

}

public static void main(String... args) throws Exception {

Main main = new Main();

XMPPConnection connection =

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(connection);



for (int i = 3; i < args.length; i++) {

main.joinAuction(connection, args[i]);

}

}

这很丑陋,但它确实向我们展示了单连接和多拍卖代码之间的区别。我们预感不久它就会被清理干净。

This is ugly, but it does show us a separation between the code for the single connection and multiple auctions. We have a hunch it’ll be cleaned up before long.

端到端测试现在向我们展示了显示无法处理我们刚刚输入的额外项目。表模型仍然硬编码为支持一行,因此其中一个项目将被忽略:

The end-to-end test now shows us that display cannot handle the additional item we’ve just fed in. The table model is still hard-coded to support one row, so one of the items will be ignored:

[...]但是...

它不是带有单元格的表格

<带有文本“item-65432”>,<带有文本“521”>,

<带有文本“521”>,<带有文本“Winning”>

因为

在第 0 行:组件 0 文本为“item-54321”

[...] but...

it is not table with row with cells

<label with text "item-65432">, <label with text "521">,

<label with text "521">, <label with text "Winning">

because

in row 0: component 0 text was "item-54321"

顺便说一句,这个结果很好地说明了为什么我们需要注意端到端测试的时间安排。在寻找auction1 或时, auction2此测试可能会失败。系统的异步性意味着我们无法判断哪个会先到达。

Incidentally, this result is a nice example of why we needed to be aware of timing in end-to-end tests. This test might fail when looking for auction1 or auction2. The asynchrony of the system means that we can’t tell which will arrive first.

扩展表模型

Extending the Table Model

需要SnipersTableModel了解多个物品,因此我们添加一个新方法来告知狙击手何时加入拍卖。我们将从中调用此方法,Main.joinAuction()以便首先显示该上下文,并编写一个空实现SnipersTableModel来满足编译器的要求:

The SnipersTableModel needs to know about multiple items, so we add a new method to tell it when the Sniper joins an auction. We’ll call this method from Main.joinAuction() so we show that context first, writing an empty implementation in SnipersTableModel to satisfy the compiler:

私有 void

joinAuction(XMPPConnection 连接,String itemId)抛出异常 {

safelyAddItemToModel(itemId);

[...]

}

私有 void safelyAddItemToModel(final String itemId)抛出异常 {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() {

snipers. addSniper (SniperSnapshot.joining(itemId));

}

});

}

private void

joinAuction(XMPPConnection connection, String itemId) throws Exception {

safelyAddItemToModel(itemId);

[...]

}

private void safelyAddItemToModel(final String itemId) throws Exception {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() {

snipers.addSniper(SniperSnapshot.joining(itemId));

}

});

}

我们必须将调用包装在其中,invokeAndWait()因为它从 Swing 线程外部改变用户界面的状态。

We have to wrap the call in an invokeAndWait() because it’s changing the state of the user interface from outside the Swing thread.

它本身的实现SnipersTableModel是单线程的,所以我们可以为它编写直接单元测试——从这个添加狙击手的测试开始:

The implementation of SnipersTableModel itself is single-threaded, so we can write direct unit tests for it—starting with this one for adding a Sniper:

@Test public void

notifiesListenersWhenAddingASniper() {

SniperSnapshot join = SniperSnapshot.joining("item123");

context.checking(new Expectations() { {

one(listener).tableChanged(with(anInsertionAtRow(0)));

}});



assertEquals(0,model.getRowCount());



model.addSniper(joining);



assertEquals(1,model.getRowCount());

assertRowMatchesSnapshot(0,joining);

}

@Test public void

notifiesListenersWhenAddingASniper() {

SniperSnapshot joining = SniperSnapshot.joining("item123");

context.checking(new Expectations() { {

one(listener).tableChanged(with(anInsertionAtRow(0)));

}});



assertEquals(0, model.getRowCount());



model.addSniper(joining);



assertEquals(1, model.getRowCount());

assertRowMatchesSnapshot(0, joining);

}

这类似于我们在“显示竞价狙击手” (第 155页) 中编写的更新狙击手状态的测试,只不过我们调用了新方法并匹配了不同的TableModelEvent。我们还将表格行值的比较打包到辅助方法中assertRowMatchesSnapshot()

This is similar to the test for updating the Sniper state that we wrote in “Showing a Bidding Sniper” (page 155), except that we’re calling the new method and matching a different TableModelEvent. We also package up the comparison of the table row values into a helper method assertRowMatchesSnapshot().

我们通过将单个SniperSnapshot字段替换为集合并触发额外的表事件来使此测试通过。这些更改破坏了现有的 Sniper 更新测试,因为不再有默认的 Sniper,因此我们对其进行了修复:

We make this test pass by replacing the single SniperSnapshot field with a collection and triggering the extra table event. These changes break the existing Sniper update test, because there’s no longer a default Sniper, so we fix it:

@Test public void

setsSniperValuesInColumns() {

SniperSnapshot join = SniperSnapshot.joining("item id");

SniperSnapshot bidding = join.bidding(555, 666);

context.checking(new Expectations() {{

allowing(listener).tableChanged(with(anyInsertionEvent()));



one(listener).tableChanged(with( aChangeInRow(0)));

}});



model.addSniper(joining);

model.sniperStateChanged(bidding);



assertRowMatchesSnapshot(0, bidding);

}

@Test public void

setsSniperValuesInColumns() {

SniperSnapshot joining = SniperSnapshot.joining("item id");

SniperSnapshot bidding = joining.bidding(555, 666);

context.checking(new Expectations() {{

allowing(listener).tableChanged(with(anyInsertionEvent()));



one(listener).tableChanged(with(aChangeInRow(0)));

}});



model.addSniper(joining);

model.sniperStateChanged(bidding);



assertRowMatchesSnapshot(0, bidding);

}

我们必须向模型添加狙击手。这会触发与此测试无关的插入事件(它只是支持基础结构),因此我们添加一个子句以允许插入通过。该子句使用更宽容的匹配器,它仅检查事件的类型,而不检查其范围。我们还更改了更新事件(我们关心allowing()的事件)的匹配器,以精确地确定它正在检查哪一行。

We have to add a Sniper to the model. This triggers an insertion event which isn’t relevant to this test—it’s just supporting infrastructure—so we add an allowing() clause to let the insertion through. The clause uses a more forgiving matcher that checks only the type of the event, not its scope. We also change the matcher for the update event (the one we do care about) to be precise about which row it’s checking.

然后我们编写更多单元测试来驱动其余功能。对于这些,我们对TableModelEvents 不感兴趣,所以我们完全忽略了listener

Then we write more unit tests to drive out the rest of the functionality. For these, we’re not interested in the TableModelEvents, so we ignore the listener altogether.

@Test public void

holdSnipersInAdditionOrder() {

context.checking(new Expectations() { {

ignoring(listener);

});



model.addSniper(SniperSnapshot.joining("item 0"));

model.addSniper(SniperSnapshot.joining("item 1"));



assertEquals("item 0", cellValue(0, Column.ITEM_IDENTIFIER));

assertEquals("item 1", cellValue(1, Column.ITEM_IDENTIFIER));

}

updatesCorrectRowForSniper() { [...]

throwsDefectIfNoExistingSniperForAnUpdate() { [...]

@Test public void

holdsSnipersInAdditionOrder() {

context.checking(new Expectations() { {

ignoring(listener);

}});



model.addSniper(SniperSnapshot.joining("item 0"));

model.addSniper(SniperSnapshot.joining("item 1"));



assertEquals("item 0", cellValue(0, Column.ITEM_IDENTIFIER));

assertEquals("item 1", cellValue(1, Column.ITEM_IDENTIFIER));

}

updatesCorrectRowForSniper() { [...]

throwsDefectIfNoExistingSniperForAnUpdate() { [...]

实现很明显。唯一有趣的一点是我们添加了一个isForSameItemAs()方法,SniperSnapshot以便它可以决定它是否引用同一个项目,而不是让表模型提取和比较标识符。1职责划分更清晰,优点是我们可以在不改变表模型的情况下更改其实现。我们还认为,找不到相关条目是一种编程错误。

The implementation is obvious. The only point of interest is that we add an isForSameItemAs() method to SniperSnapshot so that it can decide whether it’s referring to the same item, instead of having the table model extract and compare identifiers.1 It’s a clearer division of responsibilities, with the advantage that we can change its implementation without changing the table model. We also decide that not finding a relevant entry is a programming error.

1.这避免了“功能嫉妒”代码味道[Fowler99]

1. This avoids the “feature envy” code smell [Fowler99].

public void sniperStateChanged(SniperSnapshot newSnapshot) {

int row = rowMatching(newSnapshot);

snappers.set(row, newSnapshot);

fireTableRowsUpdated(row, row);

}

private int rowMatching(SniperSnapshot snap) {

for (int i = 0; i < snaps.size(); i++) {

if (snapshot.isForSameItemAs (snapshots.get(i))) { return i; } } throw new Defect( " 无法找到匹配项 " + snapper); }









public void sniperStateChanged(SniperSnapshot newSnapshot) {

int row = rowMatching(newSnapshot);

snapshots.set(row, newSnapshot);

fireTableRowsUpdated(row, row);

}

private int rowMatching(SniperSnapshot snapshot) {

for (int i = 0; i < snapshots.size(); i++) {

if (snapshot.isForSameItemAs(snapshots.get(i))) {

return i;

}

}

throw new Defect("Cannot find match for " + snapshot);

}

这使得当前的端到端测试通过——所以我们可以从待办事项列表中划掉该任务,如图 16.1 所示

This makes the current end-to-end test pass—so we can cross off the task from our to-do list, Figure 16.1.

图 16.1 狙击手处理多个物品

Figure 16.1 The Sniper handles multiple items

图像

一次性错误已经终结?

The End of Off-by-One Errors?

图像

与表格模型交互需要索引到单元格的逻辑网格中。我们发现这是 TDD 特别有用的情况。除了最简单的情况外,正确索引可能很棘手,编写测试首先要明确边界条件,然后检查我们的实现是否正确。过去,我们都浪费了太多时间寻找深埋在代码中的索引错误。

Interacting with the table model requires indexing into a logical grid of cells. We find that this is a case where TDD is particularly helpful. Getting indexing right can be tricky, except in the simplest cases, and writing tests first clarifies the boundary conditions and then checks that our implementation is correct. We’ve both lost too much time in the past searching for indexing bugs buried deep in the code.

通过用户界面添加项目

Adding Items through the User Interface

更简单的设计

A Simpler Design

买家和用户界面设计师仍在努力完善他们的想法,但他们已设法通过将商品条目移到顶部栏而不是弹出对话框来简化其原始设计。设计的当前版本如图16.2所示,因此我们需要在显示屏上添加一个文本字段和一个按钮。

The buyers and user interface designers are still working through their ideas, but they have managed to simplify their original design by moving the item entry into a top bar instead of a pop-up dialog. The current version of the design looks like Figure 16.2, so we need to add a text field and a button to the display.

图 16.2 狙击手栏中有输入字段

Figure 16.2 The Sniper with input fields in its bar

图像

尽我们所能取得进步

Making Progress While We Can

图像

用户界面设计不在本书的讨论范围内。对于任何规模的项目,用户体验专家都会考虑各种宏观和微观细节,以便为用户提供连贯的体验,因此一些团队采取的一种方法是尝试在编码之前锁定界面设计。我们的经验以及 Jeff Patton 等人的经验是,我们可以在整理设计的同时取得开发进展。我们可以根据团队当前对功能的理解进行构建,并保持我们的代码(和态度)灵活,以响应设计理念的成熟——甚至可能将我们的经验反馈到流程中。

The design of user interfaces is outside the scope of this book. For a project of any size, a user experience professional will consider all sorts of macro- and micro-details to provide the user with a coherent experience, so one route that some teams take is to try to lock down the interface design before coding. Our experience, and that of others like Jeff Patton, is that we can make development progress whilst the design is being sorted out. We can build to the team’s current understanding of the features and keep our code (and attitude) flexible to respond to design ideas as they firm up—and perhaps even feed our experience back into the process.

更新测试

Update the Test

回顾AuctionSniperEndToEndTest,它已经表达了我们希望应用程序执行的所有操作:它描述了狙击手如何连接到一个或多个拍卖和出价。变化在于,我们想要描述 中发生的某些行为的不同实现(通过用户界面而不是命令行建立连接)ApplicationRunner。我们需要进行类似于我们刚刚在 中进行的重组Main,将连接与各个拍卖分开。我们提取一个startSniper()启动和检查狙击手的方法,然后依次开始竞标每个拍卖。

Looking back at AuctionSniperEndToEndTest, it already expresses everything we want the application to do: it describes how the Sniper connects to one or more auctions and bids. The change is that we want to describe a different implementation of some of that behavior (establishing the connection through the user interface rather than the command line) which happens in the ApplicationRunner. We need a restructuring similar to the one we just made in Main, splitting the connection from the individual auctions. We pull out a startSniper() method that starts up and checks the Sniper, and then start bidding for each auction in turn.

public class ApplicationRunner {

public void startBiddingIn(final FakeAuctionServer... auctions) {

startSniper();

for (FakeAuctionServer auction : auctions) {

final String itemId = auction.getItemId();

driver.startBiddingFor(itemId);

driver.showsSniperStatus(itemId, 0, 0, textFor(SniperState.JOINING));

}

}



private void startSniper() {

// 与之前一样,没有调用 showsSniperStatus()

}

[...]

}

public class ApplicationRunner {

public void startBiddingIn(final FakeAuctionServer... auctions) {

startSniper();

for (FakeAuctionServer auction : auctions) {

final String itemId = auction.getItemId();

driver.startBiddingFor(itemId);

driver.showsSniperStatus(itemId, 0, 0, textFor(SniperState.JOINING));

}

}



private void startSniper() {

// as before without the call to showsSniperStatus()

}

[...]

}

测试基础设施的另一个变化是startBiddingFor()在 中实现新方法AuctionSniperDriver。这会查找并填写项目标识符的文本字段,然后查找并单击“加入拍卖”按钮。

The other change to the test infrastructure is implementing the new method startBiddingFor() in AuctionSniperDriver. This finds and fills in the text field for the item identifier, then finds and clicks on the Join Auction button.

公共类 AuctionSniperDriver 扩展了 JFrameDriver {

@SuppressWarnings("unchecked")

公共 void startBiddingFor(String itemId) {

itemIdField().replaceAllText(itemId);

bidButton().click();

}



私有 JTextFieldDriver itemIdField() {

JTextFieldDriver newItemId =

new JTextFieldDriver(this, JTextField.class, named(MainWindow.NEW_ITEM_ID_NAME));

newItemId.focusWithMouse();

返回 newItemId;

}



私有 JButtonDriver bidButton() {

返回新的 JButtonDriver(this, JButton.class, named(MainWindow.JOIN_BUTTON_NAME));

}

[...]

}

public class AuctionSniperDriver extends JFrameDriver {

@SuppressWarnings("unchecked")

public void startBiddingFor(String itemId) {

itemIdField().replaceAllText(itemId);

bidButton().click();

}



private JTextFieldDriver itemIdField() {

JTextFieldDriver newItemId =

new JTextFieldDriver(this, JTextField.class, named(MainWindow.NEW_ITEM_ID_NAME));

newItemId.focusWithMouse();

return newItemId;

}



private JButtonDriver bidButton() {

return new JButtonDriver(this, JButton.class, named(MainWindow.JOIN_BUTTON_NAME));

}

[...]

}

这两个组件都还不存在,因此查找文本字段的测试失败。

Neither of these components exist yet, so the test fails looking for the text field.

[...]但是...

所有顶层窗口都

包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上),

其中包含 0 个 JTextField(名称为“item id”)

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 0 JTextField (with name "item id")

添加操作栏

Adding an Action Bar

我们通过在顶部添加一个新面板来包含标识符的文本字段和“加入拍卖”按钮来解决此问题,并将活动包装在一个makeControls()方法中以帮助表达我们的意图。我们意识到此代码并不十分令人兴奋,但在添加任何行为之前,我们想先展示其结构。

We address this failure by adding a new panel across the top to contain the text field for the identifier and the Join Auction button, wrapping up the activity in a makeControls() method to help express our intent. We realize that this code isn’t very exciting, but we want to show its structure now before we add any behavior.

公共类 MainWindow 扩展 JFrame {

公共 MainWindow(TableModel snipers) {

超级(APPLICATION_TITLE);

setName(MainWindow.MAIN_WINDOW_NAME);

fillContentPane(makeSnipersTable(snipers),makeControls());

[...]

}



私有 JPanel makeControls() {

JPanel 控件 = 新 JPanel(new FlowLayout());

最终 JTextField itemIdField = 新 JTextField();

itemIdField.setColumns(25);

itemIdField.setName(NEW_ITEM_ID_NAME);

controls.add(itemIdField);



JButton joinAuctionButton = 新 JButton(“加入拍卖”);

joinAuctionButton.setName(JOIN_BUTTON_NAME);

controls.add(joinAuctionButton);



返回控件;

}

[...]

}

public class MainWindow extends JFrame {

public MainWindow(TableModel snipers) {

super(APPLICATION_TITLE);

setName(MainWindow.MAIN_WINDOW_NAME);

fillContentPane(makeSnipersTable(snipers), makeControls());

[...]

}



private JPanel makeControls() {

JPanel controls = new JPanel(new FlowLayout());

final JTextField itemIdField = new JTextField();

itemIdField.setColumns(25);

itemIdField.setName(NEW_ITEM_ID_NAME);

controls.add(itemIdField);



JButton joinAuctionButton = new JButton("Join Auction");

joinAuctionButton.setName(JOIN_BUTTON_NAME);

controls.add(joinAuctionButton);



return controls;

}

[...]

}

在操作栏到位后,我们的下一个测试会失败,因为我们没有在表模型中创建已识别的行。

With the action bar in place, our next test fails because we don’t create the identified rows in the table model.

[...]但是...

所有顶层窗口均

包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上),

其中包含 1 个 JTable()

它不包含单元格

<label with text "item-54321">, <label with text "0">,

<label with text "0">, <label with text "Joining"> 的行

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JTable ()

it is not with row with cells

<label with text "item-54321">, <label with text "0">,

<label with text "0">, <label with text "Joining">

设计时刻

A Design Moment

现在我们该做什么?回顾一下我们的情况:我们有一个有问题的验收测试,我们有用户界面结构但没有行为,并且SnipersTableModel每次仍然只能处理一个狙击手。我们的目标是,当我们单击“加入拍卖”按钮时,应用程序将尝试加入项目字段中指定的拍卖,并在拍卖列表中添加一个新行以显示请求正在处理。

Now what do we do? To review our position: we have a broken acceptance test pending, we have the user interface structure but no behavior, and the SnipersTableModel still handles only one Sniper at a time. Our goal is that, when we click on the Join Auction button, the application will attempt to join the auction specified in the item field and add a new row to the list of auctions to show that the request is being handled.

实际上,这意味着我们需要一个 SwingActionListener来处理JButton,它将使用 中的文本JTextField作为新会话的项目标识符。它的实现将向 中添加一行,SnipersTableModelChat为 Southabee 的在线服务器创建一个新的。问题在于,与连接有关的所有内容都在 中Main,而按钮和文本字段则在 中MainWindow。我们希望保持这种区别,因为它使两个类的职责保持集中。

In practice, this means that we need a Swing ActionListener for the JButton that will use the text from the JTextField as an item identifier for the new session. Its implementation will add a row to the SnipersTableModel and create a new Chat to the Southabee’s On-Line server. The catch is that everything to do with connections is in Main, whereas the button and the text field are in MainWindow. This is a distinction we’d like to maintain, since it keeps the responsibilities of the two classes focused.

我们停下来思考一下代码的结构,使用第16页“角色、职责、协作者”中提到的 CRC 卡来帮助我们形象化我们的想法。经过一番讨论,我们提醒自己,的工作是管理我们的 UI 组件及其交互;它不应该还必须管理诸如“连接”或“聊天”之类的概念。当用户交互意味着用户界面之外的操作时,应该委托给协作对象。MainWindowMainWindow

We stop for a moment to think about the structure of the code, using the CRC cards we mentioned in “Roles, Responsibilities, Collaborators” on page 16 to help us visualize our ideas. After some discussion, we remind ourselves that the job of MainWindow is to manage our UI components and their interactions; it shouldn’t also have to manage concepts such as “connection” or “chat.” When a user interaction implies an action outside the user interface, MainWindow should delegate to a collaborating object.

为了表达这一点,我们决定添加一个监听器来MainWindow通知邻近对象此类请求。我们将新协作者称为 ,UserRequestListener因为它将负责处理用户发出的请求:

To express this, we decide to add a listener to MainWindow to notify neighboring objects about such requests. We call the new collaborator a UserRequestListener since it will be responsible for handling requests made by the user:

公共接口 UserRequestListener 扩展了 EventListener {

void joinAuction(String itemId);

}

public interface UserRequestListener extends EventListener {

void joinAuction(String itemId);

}

另一层次的测试

Another Level of Testing

我们想为提议的新行为编写一个测试,但由于 Swing 线程,我们无法编写一个简单的单元测试。我们无法确定在测试结束时检查任何断言时 Swing 代码是否已完成运行,因此我们需要等待测试代码稳定下来的东西 — 我们通常称之为集成测试,因为它测试我们的代码如何与第三方库协同工作。我们可以使用 WindowLicker 进行此级别的测试以及端到端测试。以下是新测试:

We want to write a test for our proposed new behavior, but we can’t just write a simple unit test because of Swing threading. We can’t be sure that the Swing code will have finished running by the time we check any assertions at the end of the test, so we need something that will wait until the tested code has stabilized—what we usually call an integration test because it’s testing how our code works with a third-party library. We can use WindowLicker for this level of testing as well as for our end-to-end tests. Here’s the new test:

公共类 MainWindowTest {

私有最终 SnipersTableModel tableModel = new SnipersTableModel();

私有最终 MainWindow mainWindow = new MainWindow(tableModel);

私有最终 AuctionSniperDriver driver = new AuctionSniperDriver(100);



@Test public void

makesUserRequestWhenJoinButtonClicked() {

最终 ValueMatcherProbe<String> buttonProbe =

new ValueMatcherProbe<String>(equalTo("an item-id"), "加入请求");



mainWindow.addUserRequestListener(

new UserRequestListener() {

public void joinAuction(String itemId) {

buttonProbe.setReceivedValue(itemId);

}

});



driver.startBiddingFor("an item-id");

driver.check(buttonProbe);

}

}

public class MainWindowTest {

private final SnipersTableModel tableModel = new SnipersTableModel();

private final MainWindow mainWindow = new MainWindow(tableModel);

private final AuctionSniperDriver driver = new AuctionSniperDriver(100);



@Test public void

makesUserRequestWhenJoinButtonClicked() {

final ValueMatcherProbe<String> buttonProbe =

new ValueMatcherProbe<String>(equalTo("an item-id"), "join request");



mainWindow.addUserRequestListener(

new UserRequestListener() {

public void joinAuction(String itemId) {

buttonProbe.setReceivedValue(itemId);

}

});



driver.startBiddingFor("an item-id");

driver.check(buttonProbe);

}

}

WindowLicker 探测器

WindowLicker Probes

图像

在 WindowLicker 中,探测器是检查给定状态的对象。驱动程序的check()方法会反复触发给定的探测器,直到满足条件或超时。在此测试中,我们使用ValueMatcherProbe将值与 Hamcrest 匹配器进行比较的 ,以等待使用正确的拍卖标识符调用UserRequestListenerjoinAuction()

In WindowLicker, a probe is an object that checks for a given state. A driver’s check() method repeatedly fires the given probe until it’s satisfied or times out. In this test, we use a ValueMatcherProbe, which compares a value against a Hamcrest matcher, to wait for the UserRequestListener’s joinAuction() to be called with the right auction identifier.

我们创建一个空的实现MainWindow.addUserRequestListener,以通过编译器,但测试失败:

We create an empty implementation of MainWindow.addUserRequestListener, to get through the compiler, and the test fails:

尝试查找...

加入请求“一个项目 ID”

,但...

加入请求“一个项目 ID”。没有收到任何结果

Tried to look for...

join request "an item-id"

but...

join request "an item-id". Received nothing

MainWindow为了使这个测试通过,我们在使用中填写了请求监听器基础结构Announcer,这是一个管理监听器集合的实用程序类。2我们添加一个 SwingActionListener,用于提取项目标识符并将其通告给请求侦听器。相关部分MainWindow如下所示:

To make this test pass, we fill in the request listener infrastructure in MainWindow using Announcer, a utility class that manages collections of listeners.2 We add a Swing ActionListener that extracts the item identifier and announces it to the request listeners. The relevant parts of MainWindow look like this:

2.Announcer包含在 jMock 附带的示例中。

2. Announcer is included in the examples that ship with jMock.

公共类 MainWindow 扩展了 JFrame {

私有最终 Announcer<UserRequestListener> userRequests =

Announcer.to(UserRequestListener.class);



公共 void addUserRequestListener(UserRequestListener userRequestListener) {

userRequests.addListener(userRequestListener);

}



[...]

私有 JPanel makeControls(最终 SnipersTableModel snipers) {

[...]

joinAuctionButton.addActionListener(新 ActionListener() {

公共 void actionPerformed(ActionEvent e) {

userRequests.announce().joinAuction(itemIdField.getText());

}

});

[...]

}

}

public class MainWindow extends JFrame {

private final Announcer<UserRequestListener> userRequests =

Announcer.to(UserRequestListener.class);



public void addUserRequestListener(UserRequestListener userRequestListener) {

userRequests.addListener(userRequestListener);

}



[...]

private JPanel makeControls(final SnipersTableModel snipers) {

[...]

joinAuctionButton.addActionListener(new ActionListener() {

public void actionPerformed(ActionEvent e) {

userRequests.announce().joinAuction(itemIdField.getText());

}

});

[...]

}

}

为了强调这一点,我们将ActionListener用户界面框架内部的事件转换为UserRequestListener用户与拍卖互动的事件。这是两个独立的领域,MainWindow的工作就是将一个领域转换为另一个领域。MainWindow关心的任何实现如何UserRequestListener工作——那责任太大了。

To emphasize the point here, we’ve converted an ActionListener event, which is internal to the user interface framework, to a UserRequestListener event, which is about users interacting with an auction. These are two separate domains and MainWindow’s job is to translate from one to the other. MainWindow is not concerned with how any implementation of UserRequestListener might work—that would be too much responsibility.

微傲慢

Micro-Hubris

图像

如果这种级别的测试看起来有点过度,那么当我们第一次编写此示例时,我们设法返回文本字段的名称,而不是其文本— 一个是item-id,另一个是item id。这只是一种很容易被忽略的错误,并且在端到端测试中很难解决 — 这就是为什么我们也喜欢编写集成级测试。

In case this level of testing seems like overkill, when we first wrote this example we managed to return the text field’s name, not its text—one was item-id and the other was item id. This is just the sort of bug that’s easy to let slip through and a nightmare to unpick in end-to-end tests—which is why we like to also write integration-level tests.

实现 UserRequestListener

Implementing the UserRequestListener

我们返回到 ,Main看看在哪里可以插入新的UserRequestListener。更改很小,因为我们在本章前面重构类时完成了大部分工作。我们决定暂时保留大部分现有代码(即使它的形状不太正确),直到我们取得更多进展,因此我们只是将以前的joinAuction()方法内联到 中UserRequestListener。我们也很高兴删除safelyAddItemToModel()包装器,因为UserRequestListener将在 Swing 线程上调用。从目前的代码来看,这一点并不明显;我们记下来以后再处理。

We return to Main to see where we can plug in our new UserRequestListener. The changes are minor because we did most of the work when we restructured the class earlier in this chapter. We decide to preserve most of the existing code for now (even though it’s not quite the right shape) until we’ve made more progress, so we just inline our previous joinAuction() method into the UserRequestListener’s. We’re also pleased to remove the safelyAddItemToModel() wrapper, since the UserRequestListener will be called on the Swing thread. This is not obvious from the code as it stands; we make a note to address that later.

公共类 Main {

公共静态 void main(String ... args) 抛出异常 {

Main main = new Main();

XMPPConnection connection =

连接(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(连接);

main.addUserRequestListenerFor(连接);

}



private void addUserRequestListenerFor(final XMPPConnection connection) {

ui.addUserRequestListener(new UserRequestListener() {

public void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

聊天 chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

notToBeGCd.add(chat);



拍卖 auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

auction.join();

}

});

}

}

public class Main {

public static void main(String... args) throws Exception {

Main main = new Main();

XMPPConnection connection =

connection(args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(connection);

main.addUserRequestListenerFor(connection);

}



private void addUserRequestListenerFor(final XMPPConnection connection) {

ui.addUserRequestListener(new UserRequestListener() {

public void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

Chat chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

notToBeGCd.add(chat);



Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

auction.join();

}

});

}

}

我们再次尝试端到端测试,发现测试通过了。我们有些震惊,于是停下来喝咖啡。

We try our end-to-end tests again and find that they pass. Slightly stunned, we break for coffee.

观察结果

Observations

稳步前进

Making Steady Progress

我们开始看到一些重组工作带来的更多回报。将端到端测试转换为处理多个项目非常容易,并且大部分实施工作都包括梳理已经运行的代码。我们一直小心翼翼地保持类职责的集中性——除了一个地方,Main我们将所有工作妥协都放在了那里。

We’re starting to see more payback from some of our restructuring work. It was pretty easy to convert the end-to-end test to handle multiple items, and most of the implementation consisted of teasing apart code that was already working. We’ve been careful to keep class responsibilities focused—except for the one place, Main, where we’ve put all our working compromises.

我们努力保持诚实,编写足够的测试,这迫使我们考虑一些我们可能遗漏的边缘情况。我们还引入了一个新的中级“集成”测试,以便我们能够在不拖累系统其余部分的情况下完成用户界面的实现。

We made an effort to stay honest about writing enough tests, which has forced us to consider a couple of edge cases we might otherwise have left. We also introduced a new intermediate-level “integration” test to allow us to work out the implementation of the user interface without dragging in the rest of the system.

TDD 机密

TDD Confidential

我们不会把开发示例时的所有细节都写下来——那样会很无聊,而且会浪费纸张——但我们认为值得一提的是这个示例的开发过程。我们尝试了几次才让这个设计指向正确的方向,因为我们试图将行为分配给错误的对象。让我们保持诚实的是,每次尝试编写有针对性且有意义的测试时,设置和我们的断言都会不断偏离。一旦我们突破了作为程序员的不足之处,测试就会变得更加清晰。

We don’t write up everything that went into the development of our examples—that would be boring and waste paper—but we think it’s worth a note about what happened with this one. It took us a couple of attempts to get this design pointing in the right direction because we were trying to allocate behavior to the wrong objects. What kept us honest was that for each attempt to write tests that were focused and made sense, the setup and our assertions kept drifting apart. Once we’d broken through our inadequacies as programmers, the tests became much clearer.

发货了吗?

Ship It?

现在一切都正常了,我们可以继续开发更多功能了,对吧?错了。我们不认为“工作”和“完成”是一回事。我们在Main整理思路时留下了不少设计混乱,应用程序各个部分的功能都混杂在一起,如图 16.3 所示。除了这留下的混乱之外,除了通过端到端测试外,大部分代码实际上都无法测试。现在代码还很小,我们可以摆脱这种情况,但随着应用程序的增长,这种情况将很难维持。更重要的是,也许我们没有得到任何关于代码内部质量的单元测试反馈。

So now that everything works we can get on with more features, right? Wrong. We don’t believe that “working” is the same thing as “finished.” We’ve left quite a design mess in Main as we sorted out our ideas, with functionality from various slices of the application all jumbled into one, as in Figure 16.3. Apart from the confusion this leaves, most of this code is not really testable except through the end-to-end tests. We can get away with that now, while the code is still small, but it will be difficult to sustain as the application grows. More importantly, perhaps, we’re not getting any unit-test feedback about the internal quality of the code.

如果我们知道代码永远不会改变或者有紧急情况,我们可能会将此代码投入生产。我们知道第一种情况是不正确的,因为应用程序尚未完成,而匆忙并不是真正的危机。我们知道我们很快就会再次使用这段代码,所以我们可以趁它还记忆犹新时立即清理,也可以每次接触它时重新学习它。鉴于我们试图在这里提出一个教育观点,你可能已经猜到我们接下来要做什么了。

We might put this code into production if we knew the code was never going to change or there was an emergency. We know that the first isn’t true, because the application isn’t finished yet, and being in a hurry is not really a crisis. We know we will be working in this code again soon, so we can either clean up now, while it’s still fresh in our minds, or re-learn it every time we touch it. Given that we’re trying to make an educational point here, you’ve probably guessed what we’ll do next.

图 16.3 所有内容均在 Main

Figure 16.3 Everything implemented in Main

图像

第 17 章 梳理主要内容

Chapter 17. Teasing Apart Main

在其中,我们将应用程序分割开来,重新排列行为,将 XMPP 和用户界面代码与狙击逻辑隔离开来。我们逐步实现这一点,每次更改一个概念,而不会破坏整个应用程序。我们最终将木桩刺穿了 的心脏 notToBeGCd

In which we slice up our application, shuffling behavior around to isolate the XMPP and user interface code from the sniping logic. We achieve this incrementally, changing one concept at a time without breaking the whole application. We finally put a stake through the heart of notToBeGCd.

寻找角色

Finding a Role

我们已经说服自己,我们需要做一些手术Main,但我们希望我们的改进Main达到什么效果呢?

We’ve convinced ourselves that we need to do some surgery on Main, but what do we want our improved Main to do?

对于非常繁琐的程序,我们喜欢将顶级类视为“媒人”,查找组件并将它们相互介绍。完成这项工作后,它会进入后台并等待应用程序完成。从更大范围来看,这就是当前一代应用程序容器所做的事情,只是关系通常以 XML 编码。

For programs that are more than trivial, we like to think of our top-level class as a “matchmaker,” finding components and introducing them to each other. Once that job is done it drops into the background and waits for the application to finish. On a larger scale, this what the current generation of application containers do, except that the relationships are often encoded in XML.

在其当前形式下,Main它充当媒人的角色,但它实现了一些组件,这意味着它承担了太多的责任。一个线索是查看它的导入:

In its current form, Main acts as a matchmaker but it’s also implementing some of the components, which means it has too many responsibilities. One clue is to look at its imports:

导入 java.awt.event.WindowAdapter;

导入 java.awt.event.WindowEvent;

导入 java.util.ArrayList;



导入 javax.swing.SwingUtilities;



导入 org.jivesoftware.smack.Chat;

导入 org.jivesoftware.smack.XMPPConnection;

导入 org.jivesoftware.smack.XMPPException;



导入 auctionsniper.ui.MainWindow;

导入 auctionsniper.ui.SnipersTableModel;

导入 auctionsniper.AuctionMessageTranslator;

导入 auctionsniper.XMPPAuction;

import java.awt.event.WindowAdapter;

import java.awt.event.WindowEvent;

import java.util.ArrayList;



import javax.swing.SwingUtilities;



import org.jivesoftware.smack.Chat;

import org.jivesoftware.smack.XMPPConnection;

import org.jivesoftware.smack.XMPPException;



import auctionsniper.ui.MainWindow;

import auctionsniper.ui.SnipersTableModel;

import auctionsniper.AuctionMessageTranslator;

import auctionsniper.XMPPAuction;

我们从三个不相关的包以及auctionsniper包本身导入代码。事实上,我们有一个包循环,因为顶级包和 UI 包相互依赖。与其他一些语言不同,Java 可以容忍包循环,但我们不应该对此感到高兴。

We’re importing code from three unrelated packages, plus the auctionsniper package itself. In fact, we have a package loop in that the top-level and UI packages depend on each other. Java, unlike some other languages, tolerates package loops, but they’re not something we should be pleased with.

我们认为应该从中提取一些此类行为Main,而 XMPP 功能看起来是不错的首选。Smack 的使用应该是与应用程序其余部分无关的实现细节。

We think we should extract some of this behavior from Main, and the XMPP features look like a good first candidate. The use of the Smack should be an implementation detail that is irrelevant to the rest of the application.

提取聊天内容

Extracting the Chat

隔离聊天

Isolating the Chat

UserRequestListener.joinAuction()大多数操作发生在within的实现中Main。我们注意到,我们在这一代码单元中交错使用了不同的域级别、拍卖狙击和聊天。我们想将它们分开。下面是它:

Most of the action happens in the implementation of UserRequestListener.joinAuction() within Main. We notice that we’ve interleaved different domain levels, auction sniping and chatting, in this one unit of code. We’d like to split them up. Here it is again:

公共类 Main { [...]

私有 void addUserRequestListenerFor(最终 XMPPConnection 连接) {

ui.addUserRequestListener(新 UserRequestListener() {

公共 void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

聊天chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

notToBeGCd.add( chat );



拍卖 auction = 新 XMPPAuction( chat );

聊天.addMessageListener(

新 AuctionMessageTranslator(connection.getUser(),

新 AuctionSniper(itemId, auction,

新 SwingThreadSniperListener(snipers))));

auction.join();

}

});

}

}

public class Main { [...]

private void addUserRequestListenerFor(final XMPPConnection connection) {

ui.addUserRequestListener(new UserRequestListener() {

public void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

Chat chat = connection.getChatManager()

.createChat(auctionId(itemId, connection), null);

notToBeGCd.add(chat);



Auction auction = new XMPPAuction(chat);

chat.addMessageListener(

new AuctionMessageTranslator(connection.getUser(),

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers))));

auction.join();

}

});

}

}

将此代码锁定到 Smack 中的对象是chat;我们多次引用它:避免垃圾收集、将其附加到Auction实现以及附加消息侦听器。如果我们可以将与拍卖和狙击手相关的代码聚集​​在一起,我们可以将 移到其他地方,但当、和chat之间仍然存在依赖关系循环时,这很棘手。XMPPAuctionChatAuctionSniper

The object that locks this code into Smack is the chat; we refer to it several times: to avoid garbage collection, to attach it to the Auction implementation, and to attach the message listener. If we can gather together the auction- and Sniper-related code, we can move the chat elsewhere, but that’s tricky while there’s still a dependency loop between the XMPPAuction, Chat, and AuctionSniper.

再看一遍,狙击手实际上是AuctionMessageTranslator作为插入到的AuctionEventListener。也许使用将Announcer两者绑定在一起,而不是直接链接,会给我们带来所需的灵活性。将狙击手作为通知也是有意义的,如“对象对等刻板印象” (第52页) 中定义的那样。结果是:

Looking again, the Sniper actually plugs in to the AuctionMessageTranslator as an AuctionEventListener. Perhaps using an Announcer to bind the two together, rather than a direct link, would give us the flexibility we need. It would also make sense to have the Sniper as a notification, as defined in “Object Peer Stereotypes” (page 52). The result is:

公共类 Main { [...]

私有 void addUserRequestListenerFor(最终 XMPPConnection 连接) {

ui.addUserRequestListener(新 UserRequestListener() {

公共 void joinAuction(String itemId) {

聊天 chat = 连接。[...]

播音员 <AuctionEventListener> auctionEventListeners =

Announcer.to(AuctionEventListener.class);

chat.addMessageListener(

新 AuctionMessageTranslator(

connection.getUser(),

auctionEventListeners.announce()));

notToBeGCd.add(chat);



拍卖 auction = 新 XMPPAuction(chat);

auctionEventListeners .addListener(

新 AuctionSniper(itemId, auction, 新 SwingThreadSniperListener(snipers)));

auction.join();

}

}

}

}

public class Main { [...]

private void addUserRequestListenerFor(final XMPPConnection connection) {

ui.addUserRequestListener(new UserRequestListener() {

public void joinAuction(String itemId) {

Chat chat = connection.[...]

Announcer<AuctionEventListener> auctionEventListeners =

Announcer.to(AuctionEventListener.class);

chat.addMessageListener(

new AuctionMessageTranslator(

connection.getUser(),

auctionEventListeners.announce()));

notToBeGCd.add(chat);



Auction auction = new XMPPAuction(chat);

auctionEventListeners.addListener(

new AuctionSniper(itemId, auction, new SwingThreadSniperListener(snipers)));

auction.join();

}

}

}

}

这看起来更糟,但有趣的是最后三行。如果你眯着眼睛看,看起来一切都是用拍卖和狙击手来描述的(仍然有 Swing 线程问题,但我们确实告诉你要眯着眼睛看)。

This looks worse, but the interesting bit is the last three lines. If you squint, it looks like everything is described in terms of Auctions and Snipers (there’s still the Swing thread issue, but we did tell you to squint).

封装聊天

Encapsulating the Chat

从这里,我们可以将与 相关的所有内容chat(其设置和 的使用)Announcer推送到 中,向其 s的接口XMPPAuction添加管理方法。我们只是在这里展示最终结果,但我们逐步更改了代码,因此几分钟内不会出现任何问题。AuctionAuctionEventListener

From here, we can push everything to do with chat, its setup, and the use of the Announcer, into XMPPAuction, adding management methods to the Auction interface for its AuctionEventListeners. We’re just showing the end result here, but we changed the code incrementally so that nothing was broken for more than a few minutes.

公共最终类 XMPPAuction 实现拍卖 { [...]

私人最终播音员 <AuctionEventListener> auctionEventListeners = [...]

私人最终聊天聊天;



公共 XMPPAuction(XMPPConnection 连接,字符串 itemId){

聊天 = 连接.getChatManager()。createChat(

拍卖Id(itemId,连接),

新 AuctionMessageTranslator(连接.getUser(,

拍卖事件监听器.announce()));

}



私人静态字符串拍卖Id(字符串 itemId,XMPPConnection 连接){

返回 String.format(AUCTION_ID_FORMAT,itemId,连接.getServiceName());

}

}

public final class XMPPAuction implements Auction { [...]

private final Announcer<AuctionEventListener> auctionEventListeners = [...]

private final Chat chat;



public XMPPAuction(XMPPConnection connection, String itemId) {

chat = connection.getChatManager().createChat(

auctionId(itemId, connection),

new AuctionMessageTranslator(connection.getUser(),

auctionEventListeners.announce()));

}



private static String auctionId(String itemId, XMPPConnection connection) {

return String.format(AUCTION_ID_FORMAT, itemId, connection.getServiceName());

}

}

除了垃圾收集的“缺陷”之外,这将删除对 的所有Chat引用Main

Apart from the garbage collection “wart,” this removes any references to Chat from Main.

公共类 Main { [...]

私有 void addUserRequestListenerFor(最终 XMPPConnection 连接) {

ui.addUserRequestListener(新 UserRequestListener() {

公共 void joinAuction (String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

拍卖auction = 新 XMPPAuction(连接, itemId);

notToBeGCd.add( auction );

拍卖.addAuctionEventListener(

新 AuctionSniper(itemId,拍卖,

新 SwingThreadSniperListener(snipers)));

拍卖.join();

}

});

}

}

public class Main { [...]

private void addUserRequestListenerFor(final XMPPConnection connection) {

ui.addUserRequestListener(new UserRequestListener() {

public void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

Auction auction = new XMPPAuction(connection, itemId);

notToBeGCd.add(auction);

auction.addAuctionEventListener(

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers)));

auction.join();

}

});

}

}

17.1提取 XMPPAuction

Figure 17.1 With XMPPAuction extracted

图像

编写新测试

Writing a New Test

我们还为扩展的编写了一个新的集成测试,XMPPAuction以显示它可以创建Chat并附加侦听器。我们使用一些现有的端到端测试基础架构(例如FakeAuctionServer,以及CountDownLatchJava 并发库中的)来等待响应。

We also write a new integration test for the expanded XMPPAuction to show that it can create a Chat and attach a listener. We use some of our existing end-to-end test infrastructure, such as FakeAuctionServer, and a CountDownLatch from the Java concurrency libraries to wait for a response.

@Test public void

receivedEventsFromAuctionServerAfterJoining() throws Exception {

CountDownLatch auctionWasClosed = new CountDownLatch(1);



Auction auction = new XMPPAuction(connection, auctionServer.getItemId());

auction.addAuctionEventListener(auctionClosedListener( auctionWasClosed ));



auction.join();

server.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);

server.announceClosed();



assertTrue("应该已经关闭", auctionWasClosed .await(2, SECONDS));

}



private AuctionEventListener

auctionClosedListener(final CountDownLatch auctionWasClosed) {

return new AuctionEventListener() {

public void auctionClosed() { auctionWasClosed .countDown(); }

public void currentPrice(int price, int increase, PriceSource priceSource) {

// 未实现

}

};

}

@Test public void

receivesEventsFromAuctionServerAfterJoining() throws Exception {

CountDownLatch auctionWasClosed = new CountDownLatch(1);



Auction auction = new XMPPAuction(connection, auctionServer.getItemId());

auction.addAuctionEventListener(auctionClosedListener(auctionWasClosed));



auction.join();

server.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);

server.announceClosed();



assertTrue("should have been closed", auctionWasClosed.await(2, SECONDS));

}



private AuctionEventListener

auctionClosedListener(final CountDownLatch auctionWasClosed) {

return new AuctionEventListener() {

public void auctionClosed() { auctionWasClosed.countDown(); }

public void currentPrice(int price, int increment, PriceSource priceSource) {

// not implemented

}

};

}

XMPPAuction从结果来看,我们可以看出封装是有意义的,Chat因为现在它隐藏了与请求侦听器和拍卖服务之间的通信有关的所有信息,包括翻译消息。我们还可以看到AuctionMessageTranslator是此封装的内部内容,狙击手不需要看到它。因此,为了识别我们的新结构,我们将XMPPAuctionAuctionMessageTranslator移到新auctionsniper.xmpp包中,并将测试移到等效xmpp测试包中。

Looking over the result, we can see that it makes sense for XMPPAuction to encapsulate a Chat as now it hides everything to do with communicating between a request listener and an auction service, including translating the messages. We can also see that the AuctionMessageTranslator is internal to this encapsulation, the Sniper doesn’t need to see it. So, to recognize our new structure, we move XMPPAuction and AuctionMessageTranslator into a new auctionsniper.xmpp package, and the tests into equivalent xmpp test packages.

在构造函数上做出妥协

Compromising on a Constructor

图像

我们对此实现有一个疑问:构造函数包含一些实际行为。我们的经验是,繁忙的构造函数会强制执行假设,而有一天我们会想要打破这些假设,尤其是在测试时,所以我们更愿意让它们保持非常简单 — 只需设置字段。目前,我们说服自己,这是“表面”代码,是通往外部库的桥梁,只能进行集成测试,因为 Smack 类恰好具有我们试图避免的那种复杂构造函数。

We have one doubt about this implementation: the constructor includes some real behavior. Our experience is that busy constructors enforce assumptions that one day we will want to break, especially when testing, so we prefer to keep them very simple—just setting the fields. For now, we convince ourselves that this is “veneer” code, a bridge to an external library, that can only be integration-tested because the Smack classes have just the kind of complicated constructors we try to avoid.

提取连接

Extracting the Connection

接下来要删除的Main是直接引用XMPPConnection。我们可以将它们包装在一个工厂类中,该工厂类将为给定项目创建一个实例Auction,因此它将具有类似以下方法:

The next thing to remove from Main is direct references to the XMPPConnection. We can wrap these up in a factory class that will create an instance of an Auction for a given item, so it will have a method like

拍卖 auction = <工厂> .auctionFor( item id );

Auction auction = <factory>.auctionFor(item id);

我们为这个新类型该叫什么而纠结了好一阵子,因为它应该有一个能反映拍卖语言的名字。最后,我们决定安排拍卖的概念是“拍卖行”,所以我们的新类型就是这么叫的:

We struggle for a while over what to call this new type, since it should have a name that reflects the language of auctions. In the end, we decide that the concept that arranges auctions is an “auction house,” so that’s what we call our new type:

公共接口拍卖行 {

拍卖 auctionFor(String itemId);

}

public interface AuctionHouse {

Auction auctionFor(String itemId);

}

此次重构的最终结果是:

The end result of this refactoring is:

公共类 Main { [...]

公共静态 void main(String... args) 抛出异常 {

Main main = new Main();

XMPPAuctionHouse auctionHouse =

XMPPAuctionHouse.connect(

args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(auctionHouse);

main.addUserRequestListenerFor(auctionHouse);

}

私有 void addUserRequestListenerFor( final AuctionHouse auctionHouse ) {

ui.addUserRequestListener(new UserRequestListener() {

公共 void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

Auction auction = auctionHouse.auctionFor(itemId);

notToBeGCd.add(auction);

[...]

}

}

}

}

public class Main { [...]

public static void main(String... args) throws Exception {

Main main = new Main();

XMPPAuctionHouse auctionHouse =

XMPPAuctionHouse.connect(

args[ARG_HOSTNAME], args[ARG_USERNAME], args[ARG_PASSWORD]);

main.disconnectWhenUICloses(auctionHouse);

main.addUserRequestListenerFor(auctionHouse);

}

private void addUserRequestListenerFor(final AuctionHouse auctionHouse) {

ui.addUserRequestListener(new UserRequestListener() {

public void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

Auction auction = auctionHouse.auctionFor(itemId);

notToBeGCd.add(auction);

[...]

}

}

}

}

实现起来XMPPAuctionHouse很简单;我们将与 相关的所有代码都转移到那里connection,包括从拍卖品 ID 生成 Jabber ID。Main现在更简单了,只需一次导入所有 XMPP 代码,auctionsniper.xmpp.XMPPAuctionHouse。新版本如图17.2所示。

Implementing XMPPAuctionHouse is straightforward; we transfer there all the code related to connection, including the generation of the Jabber ID from the auction item ID. Main is now simpler, with just one import for all the XMPP code, auctionsniper.xmpp.XMPPAuctionHouse. The new version looks like Figure 17.2.

17.2提取 XMPPAuctionHouse

Figure 17.2 With XMPPAuctionHouse extracted

图像

为了保持一致性,我们XMPPAuctionHouse对 的集成测试进行了改造XMPPAuction,而不是XMPPAuction像现在这样直接创建 ,并将测试重命名为XMPPAuctionHouseTest

For consistency, we retrofit XMPPAuctionHouse to the integration test for XMPPAuction, instead of creating XMPPAuctions directly as it does now, and rename the test to XMPPAuctionHouseTest.

我们的最后一步是将相关常量从Main我们离开它们的位置移开:消息格式移至XMPPAuction,连接标识符格式移至XMPPAuctionHouse。这让我们确信我们正在朝着正确的方向前进,因为我们正在缩小这些常量的使用范围。

Our final touch is to move the relevant constants from Main where we’d left them: the message formats to XMPPAuction and the connection identifier format to XMPPAuctionHouse. This reassures us that we’re moving in the right direction, since we’re narrowing the scope of where these constants are used.

提取 SnipersTableModel

Extracting the SnipersTableModel

狙击发射器

Sniper Launcher

最后,我们想针对直接引用SnipersTableModel和相关的SwingThreadSniperListener——以及可怕的——做点什么notToBeGCd。我们认为我们可以做到这一点,但需要采取一些措施。

Finally, we’d like to do something about the direct reference to the SnipersTableModel and the related SwingThreadSniperListener—and the awful notToBeGCd. We think we can get there, but it’ll take a couple of steps.

第一步是将 的匿名实现转换UserRequestListener为适当的类,以便我们了解其依赖关系。我们决定将新类称为SniperLauncher,因为它将通过“发射”狙击手来响应加入拍卖的请求。一个不错的效果是,我们可以将notToBeGCd新类本地化。

The first step is to turn the anonymous implementation of UserRequestListener into a proper class so we can understand its dependencies. We decide to call the new class SniperLauncher, since it will respond to a request to join an auction by “launching” a Sniper. One nice effect is that we can make notToBeGCd local to the new class.

public class SniperLauncher 实现 UserRequestListener {

private final ArrayList<Auction> notToBeGCd = new ArrayList<Auction>();

private final AuctionHouse auctionHouse;

private final SnipersTableModel snipers ;



public SniperLauncher(AuctionHouse auctionHouse, SnipersTableModel snipers) {

// 设置字段

}



public void joinAuction(String itemId) {

snipers .addSniper(SniperSnapshot.joining(itemId));

Auction auction = auctionHouse.auctionFor(itemId);

notToBeGCd.add(auction);

AuctionSniper sniper =

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener( snipers ));

auction.addAuctionEventListener( snipers );

auction.join();

}

}

public class SniperLauncher implements UserRequestListener {

private final ArrayList<Auction> notToBeGCd = new ArrayList<Auction>();

private final AuctionHouse auctionHouse;

private final SnipersTableModel snipers;



public SniperLauncher(AuctionHouse auctionHouse, SnipersTableModel snipers) {

// set the fields

}



public void joinAuction(String itemId) {

snipers.addSniper(SniperSnapshot.joining(itemId));

Auction auction = auctionHouse.auctionFor(itemId);

notToBeGCd.add(auction);

AuctionSniper sniper =

new AuctionSniper(itemId, auction,

new SwingThreadSniperListener(snipers));

auction.addAuctionEventListener(snipers);

auction.join();

}

}

分离出来后SniperLauncher,Swing 功能在这里并不适合就更加明显了。我们使用snipers,即SnipersTableModel,很笨拙:我们通过给它一个首字母来告诉它有关新狙击手的信息SniperSnapshot,并将其附加到狙击手和拍卖。此外,我们SniperSnaphot在这里和AuctionSniper构造函数中都创建了首字母,这也存在一些隐藏的重复。

With the SniperLauncher separated out, it becomes even clearer that the Swing features don’t fit here. There’s a clue in that our use of snipers, the SnipersTableModel, is clumsy: we tell it about the new Sniper by giving it an initial SniperSnapshot, and we attach it to both the Sniper and the auction. There’s also some hidden duplication in that we create an initial SniperSnaphot both here and in the AuctionSniper constructor.

退一步来说,我们应该简化这个类,让它所做的只是建立一个新的AuctionSniper。它可以将接受新狙击手进入应用程序的过程委托给一个新角色,我们称之为SniperCollector,并在中实现SnipersTableModel

Stepping back, we ought to simplify this class so that all it does is establish a new AuctionSniper. It can delegate the process of accepting the new Sniper into the application to a new role which we’ll call a SniperCollector, implemented in the SnipersTableModel.

公共静态类 SniperLauncher 实现 UserRequestListener {

私有最终 AuctionHouse auctionHouse;

私有最终 SniperCollector 收集器;

[...]

公共无效 joinAuction(String itemId) {

Auction auction = auctionHouse.auctionFor(itemId);

AuctionSniper sniper = new AuctionSniper(itemId, auction);

auction.addAuctionEventListener(sniper);

收集器.addSniper(sniper);

auction.join();

}

}

public static class SniperLauncher implements UserRequestListener {

private final AuctionHouse auctionHouse;

private final SniperCollector collector;

[...]

public void joinAuction(String itemId) {

Auction auction = auctionHouse.auctionFor(itemId);

AuctionSniper sniper = new AuctionSniper(itemId, auction);

auction.addAuctionEventListener(sniper);

collector.addSniper(sniper);

auction.join();

}

}

我们想要确认的一个行为是,只有在其他所有设置都完成后,我们才会加入拍卖。现在代码已经隔离,我们可以使用 jMock 来States检查排序。

The one behavior that we want to confirm is that we only join the auction after everything else is set up. With the code now isolated, we can jMock a States to check the ordering.

公共类 SniperLauncherTest {

私有最终状态 auctionState = context.states("拍卖状态")

.startsAs("未加入");

[...]

@Test public void

addsNewSniperToCollectorAndThenJoinsAuction() {

final String itemId = "项目 123";

context.checking(new Expectations() {{

allowing(auctionHouse).auctionFor(itemId); will(returnValue(auction));



oneOf(auction).addAuctionEventListener(with(sniperForItem(itemId)));

when(auctionState.is("未加入"));

oneOf(sniperCollector).addSniper(with(sniperForItem(item)));

when(auctionState.is("未加入"));



one(auction).join(); then(auctionState.is("加入"));

}});



launcher.joinAuction(itemId);

}

}

public class SniperLauncherTest {

private final States auctionState = context.states("auction state")

.startsAs("not joined");

[...]

@Test public void

addsNewSniperToCollectorAndThenJoinsAuction() {

final String itemId = "item 123";

context.checking(new Expectations() {{

allowing(auctionHouse).auctionFor(itemId); will(returnValue(auction));



oneOf(auction).addAuctionEventListener(with(sniperForItem(itemId)));

when(auctionState.is("not joined"));

oneOf(sniperCollector).addSniper(with(sniperForItem(item)));

when(auctionState.is("not joined"));



one(auction).join(); then(auctionState.is("joined"));

}});



launcher.joinAuction(itemId);

}

}

其中,sniperForItem()返回与给定项目标识符关联的Matcher任何项匹配的。AuctionSniper

where sniperForItem() returns a Matcher that matches any AuctionSniper associated with the given item identifier.

我们扩展SnipersTableModel它以履行其新角色:现在它接受AuctionSnipers 而不是SniperSnapshots。为了实现这一点,我们必须将Sniper的侦听器从依赖项转换为通知,以便我们可以构造后添加一个监听器。我们还更改SnipersTableModel为使用新的 API 并不允许添加SniperSnapshot

We extend SnipersTableModel to fulfill its new role: now it accepts AuctionSnipers rather than SniperSnapshots. To make this work, we have to convert a Sniper’s listener from a dependency to a notification, so that we can add a listener after construction. We also change SnipersTableModel to use the new API and disallow adding SniperSnapshots.

公共类 SnipersTableModel 扩展了 AbstractTableModel,

实现了 SniperListener、SniperCollector

{

private final ArrayList<AuctionSniper> notToBeGCd = [...]



public void addSniper(AuctionSniper sniper) {

notToBeGCd.add(sniper);

addSniperSnapshot(sniper.getSnapshot());

sniper.addSniperListener(new SwingThreadSniperListener(this));

}



private void addSniperSnapshot(SniperSnapshot sniperSnapshot) {

snaps.add(sniperSnapshot);

int row = snaps.size() - 1;

fireTableRowsInserted(row, row);

}

}

public class SnipersTableModel extends AbstractTableModel

implements SniperListener, SniperCollector

{

private final ArrayList<AuctionSniper> notToBeGCd = [...]



public void addSniper(AuctionSniper sniper) {

notToBeGCd.add(sniper);

addSniperSnapshot(sniper.getSnapshot());

sniper.addSniperListener(new SwingThreadSniperListener(this));

}



private void addSniperSnapshot(SniperSnapshot sniperSnapshot) {

snapshots.add(sniperSnapshot);

int row = snapshots.size() - 1;

fireTableRowsInserted(row, row);

}

}

有一个变化表明我们正朝着正确的方向前进,那就是SwingThreadSniperListener现在被打包在代码的 Swing 部分,而不是通用的SniperLauncher

One change that suggests that we’re heading in the right direction is that the SwingThreadSniperListener is now packaged up in the Swing part of the code, not in the generic SniperLauncher.

狙击手组合

Sniper Portfolio

下一步,我们意识到我们还没有任何东西可以代表我们所有的狙击活动,我们可以将其称为投资组合。目前,SnipersTableModel隐式负责维护我们的狙击记录并显示该记录。它还将 Swing 实现细节拉入其中Main

As a next step, we realize that we don’t yet have anything that represents all our sniping activity and that we might call our portfolio. At the moment, the SnipersTableModel is implicitly responsible for both maintaining a record of our sniping and displaying that record. It also pulls a Swing implementation detail into Main.

我们希望更清晰地分离关注点,因此我们提取了SniperPortfolio来维护我们的狙击手,并将其作为 的新实现者SniperCollector。我们将 的创建推SnipersTableModel送到MainWindow,并将其设为 ,PortfolioListener这样投资组合就可以在我们添加或删除狙击手时通知它。

We want a clearer separation of concerns, so we extract a SniperPortfolio to maintain our Snipers, which we make our new implementer of SniperCollector. We push the creation of the SnipersTableModel into MainWindow, and make it a PortfolioListener so the portfolio can tell it when we add or remove a Sniper.

公共接口 PortfolioListener 扩展 EventListener {

void sniperAdded(AuctionSniper sniper);

}



公共类 MainWindow 扩展 JFrame {

私有 JTable makeSnipersTable(SniperPortfolio portfolio) {

SnipersTableModel model = new SnipersTableModel();

portfolio.addPortfolioListener(model);

JTable snipersTable = new JTable(model);

snipersTable.setName(SNIPERS_TABLE_NAME);

返回 snipersTable;

}

}

public interface PortfolioListener extends EventListener {

void sniperAdded(AuctionSniper sniper);

}



public class MainWindow extends JFrame {

private JTable makeSnipersTable(SniperPortfolio portfolio) {

SnipersTableModel model = new SnipersTableModel();

portfolio.addPortfolioListener(model);

JTable snipersTable = new JTable(model);

snipersTable.setName(SNIPERS_TABLE_NAME);

return snipersTable;

}

}

这使得我们的顶层代码非常简单 - 它只是通过以下方式将用户界面和狙击手创建绑定在一起portfolio

This makes our top-level code very simple—it just binds together the user interface and sniper creation through the portfolio:

公共类 Main { [...]

私有最终 SniperPortfolio投资组合= 新的 SniperPortfolio();



公共 Main() 抛出异常 {

SwingUtilities.invokeAndWait(新的 Runnable() {

公共 void run() {

ui = 新的 MainWindow(投资组合);

}

});

}



私有 void addUserRequestListenerFor(最终 AuctionHouse 拍卖House) {

ui.addUserRequestListener(新的 SniperLauncher(拍卖House,投资组合));

}

}

public class Main { [...]

private final SniperPortfolio portfolio = new SniperPortfolio();



public Main() throws Exception {

SwingUtilities.invokeAndWait(new Runnable() {

public void run() {

ui = new MainWindow(portfolio);

}

});

}



private void addUserRequestListenerFor(final AuctionHouse auctionHouse) {

ui.addUserRequestListener(new SniperLauncher(auctionHouse, portfolio));

}

}

甚至更好的是,由于SniperPortfolio维护了所有狙击手的列表,我们最终可以摆脱notToBeGCd

Even better, since SniperPortfolio maintains a list of all the Snipers, we can finally get rid of notToBeGCd.

重构后,我们得到了如图 17.3所示的结构。我们将代码分成了三个部分:一个用于核心应用程序,一个用于 XMPP 通信,一个用于 Swing 显示。我们稍后会回到这部分。

This refactoring takes us to the structure shown in Figure 17.3. We’ve separated the code into three components: one for the core application, one for XMPP communication, and one for Swing display. We’ll return to this in a moment.

图 17.3 使用 SniperPortfolio

Figure 17.3 With the SniperPortfolio

图像

现在我们已经清理完毕,我们可以从列表中划掉下一个项目:图 17.4

Now that we’ve cleaned up, we can cross the next item off our list: Figure 17.4.

图 17.4 通过用户界面添加项目

Figure 17.4 Adding items through the user interface

图像

观察结果

Observations

增量架构

Incremental Architecture

这次重组Main是应用程序开发的关键时刻。

This restructuring of Main is a key moment in the development of the application.

如图17.5所示,我们现在拥有一个与我们在“可维护性设计”(第47页)中描述的“端口和适配器”体系结构相匹配的结构。核心领域代码(例如AuctionSniper)依赖于桥接代码(例如SnipersTableModel),后者驱动或响应技术代码(例如JTable)。我们保持领域代码不引用任何外部基础架构。包的内容auctionsniper使用自包含的语言定义了拍卖狙击业务的模型。例外是Main,它是我们的入口点,将领域模型和基础架构绑定在一起。

As Figure 17.5 shows, we now have a structure that matches the “ports and adapters” architecture we described in “Designing for Maintainability” (page 47). There is core domain code (for example, AuctionSniper) which depends on bridging code (for example, SnipersTableModel) that drives or responds to technical code (for example, JTable). We’ve kept the domain code free of any reference to the external infrastructure. The contents of our auctionsniper package define a model of our auction sniping business, using a self-contained language. The exception is Main, which is our entry point and binds the domain model and infrastructure together.

图 17.5 应用程序现在有一个“端口和适配器”架构

Figure 17.5 The application now has a “ports and adapters” architecture

图像

对于本示例而言,重要的是,我们通过添加功能和反复遵循启发式方法逐步实现此设计。虽然我们依靠经验来指导决策,但我们几乎通过遵循代码并注意保持其整洁而自动实现了此解决方案。

What’s important for the purposes of this example, is that we arrived at this design incrementally, by adding features and repeatedly following heuristics. Although we rely on our experience to guide our decisions, we reached this solution almost automatically by just following the code and taking care to keep it clean.

三点接触

Three-Point Contact

我们详细地写下了重构过程,因为我们想在此过程中提出一些要点,并表明我们可以逐步进行重大重构。当我们不确定下一步该做什么或如何从这里到达那里时,一种应对方法是缩小我们所做的个别更改,正如 Kent Beck 在[Beck02]中展示的那样。通过反复修复代码中的局部问题,我们发现我们可以安全地探索设计,而不会偏离工作代码超过几分钟。通常这足以引导我们走向更好的设计,如果它不起作用,我们总是可以回溯并采取另一条路径。

We wrote this refactoring up in detail because we wanted to make some points along the way and to show that we can do significant refactorings incrementally. When we’re not sure what to do next or how to get there from here, one way of coping is to scale down the individual changes we make, as Kent Beck showed in [Beck02]. By repeatedly fixing local problems in the code, we find we can explore the design safely, never straying more than a few minutes from working code. Usually this is enough to lead us towards a better design, and we can always backtrack and take another path if it doesn’t work out.

可以这样理解“三点接触”的攀岩规则。训练有素的攀岩者每次只移动一个肢体(一只手或一只脚),以最大程度地降低跌落的风险。每个动作都很简单且安全,但只要结合足够多的动作,你就能到达路线的顶端。

One way to think of this is the rock climbing rule of “three-point contact.” Trained climbers only move one limb at a time (a hand or a foot), to minimize the risk of falling off. Each move is minimal and safe, but combining enough of them will get you to the top of the route.

从“耗时”来看,这次重构所花的时间并不比您阅读它所花的时间多多少,我们认为这对于更清晰地分离关注点来说是一个很好的回报。凭借经验,我们学会了识别代码中的错误行,因此我们通常可以采取更直接的路线。

In “elapsed time,” this refactoring didn’t take much longer than the time you spent reading it, which we think is a good return for the clearer separation of concerns. With experience, we’ve learned to recognize fault lines in code so we can often take a more direct route.

动态和静态设计

Dynamic as Well as Static Design

在编写本章的代码时,我们确实遇到了一个小问题。Steve 正在提取SniperPortfolio并卡在尝试确保sniperAdded()在 Swing 线程中调用该方法。最后他想起该事件无论如何都是通过单击按钮触发的,所以他已经解决了。

We did encounter one small bump whilst working on the code for this chapter. Steve was extracting the SniperPortfolio and got stuck trying to ensure that the sniperAdded() method was called within the Swing thread. Eventually he remembered that the event is triggered by a button click anyway, so he was already covered.

我们从中学到的是(除了编写书中示例时需要配对之外),重构代码时应该考虑多个视图。重构毕竟是一种设计活动,这意味着我们仍然需要我们学到的所有技能——只是现在我们需要它们一直存在而不是定期存在。重构过于关注静态结构(类和接口),以至于很容易忽视应用程序的动态结构(实例和线程)。有时我们只需要退后一步,画出一个如图 17.6所示的交互图:

What we learn from this (apart from the need for pairing while writing book examples) is that we should consider more than one view when refactoring code. Refactoring is, after all, a design activity, which means we still need all the skills we were taught—except that now we need them all the time rather than periodically. Refactoring is so focused on static structure (classes and interfaces) that it’s easy to lose sight of an application’s dynamic structure (instances and threads). Sometimes we just need to step back and draw out, say, an interaction diagram like Figure 17.6:

图 17.6 交互图

Figure 17.6 An Interaction Diagram

图像

notToBeGCd 的另一种解决方法

An Alternative Fix to notToBeGCd

我们选择的修复方法依赖于SniperPortfolio对引用的保留。在实践中,情况可能确实如此,但如果它发生变化,我们将遇到难以追踪的瞬时故障。我们依靠应用程序的副作用来修复 XMPP 代码中的问题。

Our chosen fix relies on the SniperPortfolio holding onto the reference. That’s likely to be the case in practice, but if it ever changes we will get transient failures that are hard to track down. We’re relying on a side effect of the application to fix an issue in the XMPP code.

另一种说法是,这是一个 Smack 问题,所以我们的 XMPP 层应该处理它。我们可以让sXMPPAuctionHouse挂起XMPPAuction它所创建的 s,在这种情况下,我们必须添加某种生命周期监听器来告诉我们何时完成Auction并可以释放它。这里没有明显的选择;我们只需要看看情况并做出一些判断。

An alternative would be to say that it’s a Smack problem, so our XMPP layer should deal with it. We could make the XMPPAuctionHouse hang on to the XMPPAuctions it creates, in which case we’d to have to add a lifecycle listener of some sort to tell us when we’re finished with an Auction and can release it. There is no obvious choice here; we just have to look at the circumstances and exercise some judgment.

第 18 章 填写详细信息

Chapter 18. Filling In the Details

我们引入了止损价,这样我们就不会无限竞价,这意味着我们现在可能会失去尚未结束的拍卖。我们在用户界面中添加了一个新字段,并将其推送到狙击手。我们意识到我们应该 Item 早点创建一个类型。

In which we introduce a stop price so we don’t bid infinitely, which means we can now be losing an auction that hasn’t yet closed. We add a new field to the user interface and push it through to the Sniper. We realize we should have created an Item type much earlier.

更有用的应用程序

A More Useful Application

到目前为止,该功能一直被优先考虑,以吸引潜在客户,让他们了解应用程序的外观。我们可以展示正在添加的物品和一些狙击功能。这不是一个非常有用的应用程序,因为除其他外,物品的竞标没有上限——部署起来可能非常昂贵。

So far the functionality has been prioritized to attract potential customers by giving them a sense of what the application will look like. We can show items being added and some features of sniping. It’s not a very useful application because, amongst other things, there’s no upper limit for bidding on an item—it could be very expensive to deploy.

这是使用敏捷开发技术开展新项目时的常见模式。团队足够灵活,能够应对赞助商的需求随时间的变化:一开始,重点可能是证明概念以吸引足够的支持以继续进行;后来,重点可能是实现足够的功能以准备部署;再后来,重点可能会转变为提供更多选项以支持更广泛的用户。

This is a common pattern when using Agile Development techniques to work on a new project. The team is flexible enough to respond to how the needs of the sponsors change over time: at the beginning, the emphasis might be on proving the concept to attract enough support to continue; later, the emphasis might be on implementing enough functionality to be ready to deploy; later still, the emphasis might change to providing more options to support a wider range of users.

这种动态与固定设计方法和编码修复方法非常不同,固定设计方法要求在工作开始之前批准开发结构,而编码修复方法虽然最初是成功的,但系统可能没有足够的弹性来适应其不断变化的角色。

This dynamic is very different from both a fixed design approach, where the structure of the development has to be approved before work can begin, and a code-and-fix approach, where the system might be initially successful but not resilient enough to adapt to its changing role.

受够了就停下来

Stop When We’ve Had Enough

我们的下一个最紧迫的任务(特别是在最近金融市场危机之后)是能够为我们对某个项目的出价设定一个上限,即“止损价”。

Our next most pressing task (especially after recent crises in the financial markets) is to be able to set an upper limit, the “stop price,” for our bid for an item.

引入失败状态

Introducing a Losing State

引入止损价后,狙击手在拍卖结束前就有可能输掉Lost。我们可以通过将狙击手标记为达到止损价来实现这一点,但用户希望在退出后知道拍卖结束时的最终价格,因此我们将其建模为一个额外状态。一旦狙击手的出价高于止损价,它将永远无法获胜,所以唯一的选择就是等待拍卖结束,接受其他竞标者发布的任何新的(更高的)价格更新。

With the introduction of a stop price, it’s possible for a Sniper to be losing before the auction has closed. We could implement this by just marking the Sniper as Lost when it hits its stop price, but the users want to know the final price when the auction has finished after they’ve dropped out, so we model this as an extra state. Once a Sniper has been outbid at its stop price, it will never be able to win, so the only option left is to wait for the auction to close, accepting updates of any new (higher) prices from other bidders.

我们调整了图 9.3中绘制的状态机,以包含新的转换。结果如图 18.1所示。

We adapt the state machine we drew in Figure 9.3 to include the new transitions. The result is Figure 18.1.

图 18.1 投标人现在可能失败

Figure 18.1 A bidder may now be losing

图像

第一次失败的测试

The First Failing Test

当然,我们先从失败的测试开始。我们不会在这里介绍所有的情况,但这个例子会带我们了解基本情况。首先,我们编写一个端到端测试来描述新功能。它展示了一个场景,我们的狙击手竞标一件物品,但因为物品触及了止损价而失败,而其他竞标者继续竞标直到拍卖结束。

Of course we start with a failing test. We won’t go through all the cases here, but this example will take us through the essentials. First, we write an end-to-end test to describe the new feature. It shows a scenario where our Sniper bids for an item but loses because it bumps into its stop price, and other bidders continue until the auction closes.

@Test public void sniperLosesAnAuctionWhenThePriceIsTooHigh() 抛出异常 {

auction.startSellingItem();

application.startBiddingWithStopPrice(auction, 1100);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);

auction.reportPrice(1000, 98, "其他竞标者");

application.hasShownSniperIsBidding(auction, 1000, 1098);



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1197, 10, "第三方");

application.hasShownSniperIsLosing(auction, 1197, 1098);



auction.reportPrice(1207, 10, "第四方");

应用程序.hasShownSniperIsLosing(拍卖,1207,1098);



拍卖.announceClosed();

应用程序.showsSniperHasLostAuction(拍卖,1207,1098);

}

@Test public void sniperLosesAnAuctionWhenThePriceIsTooHigh() throws Exception {

auction.startSellingItem();

application.startBiddingWithStopPrice(auction, 1100);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);

auction.reportPrice(1000, 98, "other bidder");

application.hasShownSniperIsBidding(auction, 1000, 1098);



auction.hasReceivedBid(1098, ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(1197, 10, "third party");

application.hasShownSniperIsLosing(auction, 1197, 1098);



auction.reportPrice(1207, 10, "fourth party");

application.hasShownSniperIsLosing(auction, 1207, 1098);



auction.announceClosed();

application.showsSniperHasLostAuction(auction, 1207, 1098);

}

此测试将两个新方法引入到我们的测试基础结构中,我们需要填写这些方法才能通过编译器。首先,startBiddingWithStopPrice()将新的止损价格值通过传递ApplicationRunnerAuctionSniperDriver

This test introduces two new methods into our test infrastructure, which we need to fill in to get through the compiler. First, startBiddingWithStopPrice() passes the new stop price value through the ApplicationRunner to the AuctionSniperDriver.

公共类 AuctionSniperDriver 扩展了 JFrameDriver {

公共 void startBiddingFor(String itemId,int stopPrice ) {

textField(NEW_ITEM_ID_NAME).replaceAllText(itemId);

textField(NEW_ITEM_STOP_PRICE_NAME).replaceAllText(String.valueOf(stopPrice));

bidButton().click();

}

[...]

}

public class AuctionSniperDriver extends JFrameDriver {

public void startBiddingFor(String itemId, int stopPrice) {

textField(NEW_ITEM_ID_NAME).replaceAllText(itemId);

textField(NEW_ITEM_STOP_PRICE_NAME).replaceAllText(String.valueOf(stopPrice));

bidButton().click();

}

[...]

}

这意味着我们需要在用户界面中为止损价添加一个新的输入字段,因此我们创建一个常量来标识它MainWindow(我们将很快填写组件本身)。我们还需要支持没有止损价的现有测试,因此我们将其更改为用于Integer.MAX_VALUE表示根本没有止损价。

This implies that we need a new input field in the user interface for the stop price, so we create a constant to identify it in MainWindow (we’ll fill in the component itself soon). We also need to support our existing tests which do not have a stop price, so we change them to use Integer.MAX_VALUE to represent no stop price at all.

中的另一个新方法ApplicationRunnerhasShownSniperIsLosing(),它与其他检查方法相同,只是它使用了Losing中的新值SniperState

The other new method in ApplicationRunner is hasShownSniperIsLosing(), which is the same as the other checking methods, except that it uses a new Losing value in SniperState:

公共枚举 SniperState {

LOSING {

@Override public SniperState whenAuctionClosed() { return LOST; }

}, [...]

public enum SniperState {

LOSING {

@Override public SniperState whenAuctionClosed() { return LOST; }

}, [...]

并且,为了完成循环,我们在中向显示文本添加一个值SnipersTableModel

and, to complete the loop, we add a value to the display text in SnipersTableModel:

private final static String[] STATUS_TEXT = {

"加入", "竞标", "获胜", "失败" , "失败", "获胜"

};

private final static String[] STATUS_TEXT = {

"Joining", "Bidding", "Winning", "Losing", "Lost", "Won"

};

失败消息表明我们没有止损价格字段:

The failure message says that we have no stop price field:

[...]但是...

所有顶层窗口均

包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上),

且包含 0 个 JTextField(名称为“停止价格”)

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 0 JTextField (with name "stop price")

现在我们有一个失败的端到端测试,描述了我们对该功能的意图,因此我们可以实现它。

Now we have a failing end-to-end test that describes our intentions for the feature, so we can implement it.

输入止损价

Typing In the Stop Price

为了取得任何进展,我们必须在用户界面上添加一个组件来接受止损价。我们当前的设计(如图 16.2所示)只有一个用于项目标识符的字段,但我们可以轻松调整它以在顶部栏中接受止损价。

To make any progress, we must add a component to the user interface that will accept a stop price. Our current design, which we saw in Figure 16.2, has only a field for the item identifier but we can easily adjust it to take a stop price in the top bar.

在我们的实现中,我们将添加一个JFormattedTextField用于止损价的 ,该止损价被限制为仅接受整数值,以及几个标签。新的顶部栏如图18.2所示。

For our implementation, we will add a JFormattedTextField for the stop price that is constrained to accept only integer values, and a couple of labels. The new top bar looks like Figure 18.2.

图 18.2 狙击手在其栏中带有止损价字段

Figure 18.2 The Sniper with a stop price field in its bar

图像

我们得到了预期的测试失败,也就是说狙击手没有输,因为它继续出价:

We get the test failure we expect, which is that the Sniper is not losing because it continues to bid:

[...]但是...

所有顶层窗口都

包含 1 个 JFrame(名称为“Auction Sniper Main”并显示在屏幕上),

其中包含 1 个 JTable()

它不是带有单元格的行表

<label with text "item-54321">, <label with text "1098">,

<label with text "1197">, <label with text "Losing">

因为

在第 0 行:组件 1 的文本为“1197”

[...] but...

all top level windows

contained 1 JFrame (with name "Auction Sniper Main" and showing on screen)

contained 1 JTable ()

it is not table with row with cells

<label with text "item-54321">, <label with text "1098">,

<label with text "1197">, <label with text "Losing">

because

in row 0: component 1 text was "1197"

传播止损价格

Propagating the Stop Price

为了使此功能正常工作,我们需要将止损价从用户界面传递到AuctionSniper,然后可以使用它来限制进一步的竞价。链在MainWindow通知其UserRequestListener使用时启动:

To make this feature work, we need to pass the stop price from the user interface to the AuctionSniper, which can then use it to limit further bidding. The chain starts when MainWindow notifies its UserRequestListener using:

无效加入拍卖(字符串itemId);

void joinAuction(String itemId);

显而易见的做法是stopPrice向此方法添加一个参数,并向其余的调用链添加一个参数,直到到达类AuctionSniper。我们想在这里强调一点,所以我们将强制采用略有不同的方法来传播新值。

The obvious thing to do is to add a stopPrice argument to this method and to the rest of the chain of calls, until it reaches the AuctionSniper class. We want to make a point here, so we’ll force a slightly different approach to propagating the new value.

另一种看待它的方式是,用户界面构建了用户对狙击手竞标物品的“政策”的描述。到目前为止,这只包括物品的标识符(“对该物品出价”),但现在我们添加了止损价(“对该物品出价最高至此金额”),因此结构更加完整。

Another way to look at it is that the user interface constructs a description of the user’s “policy” for the Sniper’s bidding on an item. So far this has only included the item’s identifier (“bid on this item”), but now we’re adding a stop price (“bid up to this amount on this item”) so there’s more structure.

我们想让这个结构明确,所以我们创建一个新类Item。我们从一个简单的值开始,它只带有标识符和止损价作为公共不可变字段;我们可以稍后将行为移入其中。

We want to make this structure explicit, so we create a new class, Item. We start with a simple value that just carries the identifier and stop price as public immutable fields; we can move behavior into it later.

公共类 Item {

公共最终字符串标识符;

公共最终 int stopPrice;



公共 Item(String 标识符, int stopPrice) {

this.identifier = identifier;

this.stopPrice = stopPrice;

}

// 同样是 equals()、hashCode()、toString()

}

public class Item {

public final String identifier;

public final int stopPrice;



public Item(String identifier, int stopPrice) {

this.identifier = identifier;

this.stopPrice = stopPrice;

}

// also equals(), hashCode(), toString()

}

引入类是我们在“值类型” (第59页)中描述的一个分支Item示例。它是一种占位符类型,我们用它来标识一个概念,并随着代码的增长,它为我们提供了附加相关新功能的地方。

Introducing the Item class is an example of budding off that we described in “Value Types” (page 59). It’s a placeholder type that we use to identify a concept and that gives us somewhere to attach relevant new features as the code grows.

我们深入Item研究代码,看看哪里出了问题,首先是UserRequestListener

We push Item into the code and see what breaks, starting with UserRequestListener:

图像

首先,我们修复了第 16 章MainWindowTest中为 Swing 实现编写的集成测试。语言已经开始转变。在此测试的先前版本中,探测变量称为,它描述了用户界面的结构。这不再有意义,因此我们将其重命名为,它描述了与邻居之间的协作。buttonProbeitemProbeMainWindow

First we fix MainWindowTest, the integration test we wrote for the Swing implementation in Chapter 16. The language is already beginning to shift. In the previous version of this test, the probe variable was called buttonProbe, which describes the structure of the user interface. That doesn’t make sense any more, so we’ve renamed it itemProbe, which describes a collaboration between MainWindow and its neighbors.

@Test public void

makesUserRequestWhenJoinButtonClicked() {

final ValueMatcherProbe <Item> itemProbe =

new ValueMatcherProbe <Item> (equalTo (new Item("an item-id", 789) ), "item request");

mainWindow.addUserRequestListener(

new UserRequestListener() {

public void joinAuction( Item item ) {

itemProbe .setReceivedValue(item);

}

});

driver.startBiddingFor("an item-id", 789 );

driver.check( itemProbe );

}

@Test public void

makesUserRequestWhenJoinButtonClicked() {

final ValueMatcherProbe<Item> itemProbe =

new ValueMatcherProbe<Item>(equalTo(new Item("an item-id", 789)), "item request");

mainWindow.addUserRequestListener(

new UserRequestListener() {

public void joinAuction(Item item) {

itemProbe.setReceivedValue(item);

}

});

driver.startBiddingFor("an item-id", 789);

driver.check(itemProbe);

}

我们通过提取内的止损价格值来使本次测试通过MainWindow

We make this test pass by extracting the stop price value within MainWindow.

joinAuctionButton.addActionListener(new ActionListener() {

public void actionPerformed(ActionEvent e) {

userRequests.announce().joinAuction( new Item(itemId(), stopPrice()) );

}

private String itemId() {

return itemIdField.getText();

}

private int stopPrice() {

return ((Number)stopPriceField.getValue()).intValue();

}

});

joinAuctionButton.addActionListener(new ActionListener() {

public void actionPerformed(ActionEvent e) {

userRequests.announce().joinAuction(new Item(itemId(), stopPrice()));

}

private String itemId() {

return itemIdField.getText();

}

private int stopPrice() {

return ((Number)stopPriceField.getValue()).intValue();

}

});

这会将其推ItemSniperLauncherwhich ,反过来,又将其推入其依赖类型,例如AuctionHouseAuctionSniper。我们修复编译错误并使所有测试再次通过 — 除了我们尚未实现的未完成的端到端测试。

This pushes Item into SniperLauncher which, in turn, pushes it through to its dependent types such as AuctionHouse and AuctionSniper. We fix the compilation errors and make all the tests pass again—except for the outstanding end-to-end test which we have yet to implement.

现在,我们已经明确了该领域中的另一个概念。我们意识到,物品的标识符只是用户在拍卖中出价的方式的一部分。现在,代码可以准确地告诉我们在何处做出有关出价选择的决策,因此我们不必遵循一系列方法调用来查看哪些字符串是相关的。

We’ve now made explicit another concept in the domain. We realize that an item’s identifier is only one part of how a user bids in an auction. Now the code can tell us exactly where decisions are made about bidding choices, so we don’t have to follow a chain of method calls to see which strings are relevant.

限制拍卖狙击手

Restraining the AuctionSniper

完成任务的最后一步是让AuctionSniper观察我们刚刚传递给它的停止价格并停止竞标。实际上,我们可以通过为图 18.1中绘制的每个新状态转换编写单元测试来确保我们已经涵盖了所有内容。我们的第一个测试触发狙击手开始竞标,然后宣布超出其限制的出价——停止价格设置为1234。我们还将一个共同的期望提取到一个辅助方法中。1

The last step to finish the task is to make the AuctionSniper observe the stop price we’ve just passed to it and stop bidding. In practice, we can ensure that we’ve covered everything by writing unit tests for each of the new state transitions drawn in Figure 18.1. Our first test triggers the Sniper to start bidding and then announces a bid outside its limit—the stop price is set to 1234. We’ve also extracted a common expectation into a helper method.1

1. jMock 允许checking()在一个测试中被多次调用。

1. jMock allows checking() to be called multiple times within a test.

@Test public void

doesNotBidAndReportsLosingIfSubsequentPriceIsAboveStopPrice() {

allowingSniperBidding();

context.checking(new Expectations() {{

int bid = 123 + 45;

allowing(auction).bid(bid);

atLeast(1).of(sniperListener).sniperStateChanged(

new SniperSnapshot(ITEM_ID, 2345, bid, LOSING));

when(sniperState.is("bidding"));

}});

sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);

sniper.currentPrice(2345, 25, PriceSource.FromOtherBidder);

}

私有 void allowingSniperBidding() {

context.checking(new Expectations() {{

allowing(sniperListener).sniperStateChanged(with(aSniperThatIs(BIDDING)));

然后(sniperState.is("bidding"));

}});

}

@Test public void

doesNotBidAndReportsLosingIfSubsequentPriceIsAboveStopPrice() {

allowingSniperBidding();

context.checking(new Expectations() {{

int bid = 123 + 45;

allowing(auction).bid(bid);

atLeast(1).of(sniperListener).sniperStateChanged(

new SniperSnapshot(ITEM_ID, 2345, bid, LOSING));

when(sniperState.is("bidding"));

}});

sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);

sniper.currentPrice(2345, 25, PriceSource.FromOtherBidder);

}

private void allowingSniperBidding() {

context.checking(new Expectations() {{

allowing(sniperListener).sniperStateChanged(with(aSniperThatIs(BIDDING)));

then(sniperState.is("bidding"));

}});

}

区分测试设置和断言

Distinguishing between Test Setup and Assertions

图像

我们再次使用该allowing子句来区分测试设置(使 进入AuctionSniper正确状态)和重要的测试断言(正在丢失)。我们对这种表达能力非常挑剔,因为我们发现这是测试保持有意义并因此随着时间的推移有用的唯一方法。我们将在第 21 章和第 24 章AuctionSniper中详细讨论这一点。

Once again we’re using the allowing clause to distinguish between the test setup (getting the AuctionSniper into the right state) and the significant test assertion (that the AuctionSniper is now losing). We’re very picky about this kind of expressiveness because we’ve found it’s the only way for the tests to remain meaningful, and therefore useful, over time. We return to this at length in Chapter 21 and Chapter 24.

其余测试类似:

The other tests are similar:

如果第一个价格高于止损价则不出价并报告亏损()

如果拍卖在亏损时关闭则报告亏损()

如果一旦止损价已达到则继续亏损()

如果获胜后价格高于止损价则不出价并报告亏损()

doesNotBidAndReportsLosingIfFirstPriceIsAboveStopPrice()

reportsLostIfAuctionClosesWhenLosing()

continuesToBeLosingOnceStopPriceHasBeenReached()

doesNotBidAndReportsLosingIfPriceAfterWinningIsAboveStopPrice()

我们更改AuctionSniper,并使用SniperSnapshot和中的支持功能Item,以使测试通过:

We change AuctionSniper, with supporting features in SniperSnapshot and Item, to make the test pass:

公共类 AuctionSniper { [...]

公共 void currentPrice(int price, int increase, PriceSource priceSource) {

switch(priceSource) {

case FromSniper:

snap = snap.winning(price);

break;

case FromOtherBidder:

int bid = price + increase;

if ( item.allowsBid(bid) ) {

auction.bid(bid);

snap = snap.bidding(price, bid);

} else {

snap = snap.losing(price);

}

break;

}

notifyChange();

} [...]



公共类 SniperSnapshot { [...]

公共 SniperSnapshot losing(int newLastPrice) {

return new SniperSnapshot(itemId, newLastPrice, lastBid, LOSING);

} [...]



公共类 Item { [...]

公共布尔允许Bid(int bid) {

return bid <= stopPrice;

} [... ]

public class AuctionSniper { [...]

public void currentPrice(int price, int increment, PriceSource priceSource) {

switch(priceSource) {

case FromSniper:

snapshot = snapshot.winning(price);

break;

case FromOtherBidder:

int bid = price + increment;

if (item.allowsBid(bid)) {

auction.bid(bid);

snapshot = snapshot.bidding(price, bid);

} else {

snapshot = snapshot.losing(price);

}

break;

}

notifyChange();

} [...]



public class SniperSnapshot { [...]

public SniperSnapshot losing(int newLastPrice) {

return new SniperSnapshot(itemId, newLastPrice, lastBid, LOSING);

} [...]



public class Item { [...]

public boolean allowsBid(int bid) {

return bid <= stopPrice;

} [...]

端到端测试通过后,我们可以将该功能从列表中划掉,如图 18.3 所示

The end-to-end tests pass and we can cross the feature off our list, Figure 18.3.

图 18.3 狙击手以止损价停止竞标

Figure 18.3 The Sniper stops bidding at the stop price

图像

观察结果

Observations

用户界面逐步完善

User Interfaces, Incrementally

看起来我们在开发的后期又对用户界面进行了重大更改。难道我们不应该预见到这一点吗?这是敏捷用户体验社区中一个活跃的讨论话题,答案一如既往是“视情况而定,但您拥有的灵活性比您想象的要大。”

It looks like we’re making significant changes again to the user interface at a late stage in our development. Shouldn’t we have seen this coming? This is an active topic for discussion in the Agile User Experience community and, as always, the answer is “it depends, but you have more flexibility than you might think.”

事实上,对于像这样的简单应用程序,一开始就更详细地设计用户界面是有意义的,以确保其可用性和连贯性。话虽如此,我们也想强调一点,即我们可以响应不断变化的需求,特别是如果我们构建测试和代码,使它们灵活,而不是成为负担。我们都知道需求会发生变化,特别是一旦我们将应用程序投入生产,所以我们应该能够做出响应。

In truth, for a simple application like this it would make sense to work out the user interface in more detail at the start, to make sure it’s usable and coherent. That said, we also wanted to make a point that we can respond to changing needs, especially if we structure our tests and code so that they’re flexible, not a dead weight. We all know that requirements will change, especially once we put our application into production, so we should be able to respond.

其他建模技术仍然有效

Other Modeling Techniques Still Work

一些关于 TDD 的介绍似乎表明它取代了所有以前的软件设计技术。我们认为,当 TDD 基于从尽可能广泛的经验中获得的技能和判断时,它会发挥最佳效果——其中包括利用较旧的技术和格式(我们希望我们在这里不会引起太大的争议)。

Some presentations of TDD appear to suggest that it supersedes all previous software design techniques. We think TDD works best when it’s based on skill and judgment acquired from as wide an experience as possible—which includes taking advantage of older techniques and formats (we hope we’re not being too controversial here).

状态转换图就是采取另一种观点的一个例子。我们经常遇到这样的团队,他们从来没有弄清楚他们领域中关键概念的有效状态和转换是什么,并应用这种简单的形式化通常意味着我们可以清理代码中散落的零散行为片段。状态转换图的优点在于它们可以直接映射到测试上,因此我们可以表明我们已经涵盖了所有可能性。

State transition diagrams are one example of taking another view. We regularly come across teams that have never quite figured out what the valid states and transitions are for key concepts in their domain, and applying this simple formalism often means we can clean up a lucky-dip of snippets of behavior scattered across the code. What’s nice about state transitions diagrams is that they map directly onto tests, so we can show that we’ve covered all the possibilities.

诀窍在于理解并使用其他建模技术来获得支持和指导,而不是将其作为最终目的 — — 这正是它们一开始就声名狼藉的原因。当我们在进行 TDD 并且不确定该做什么时,有时退后一步并打开一包索引卡,或者勾勒出交互,可以帮助我们重新找到方向。

The trick is to understand and use other modeling techniques for support and guidance, not as an end in themselves—which is how they got a bad name in the first place. When we’re doing TDD and we’re uncertain what to do, sometimes stepping back and opening a pack of index cards, or sketching out the interactions, can help us regain direction.

域类型比字符串更好

Domain Types Are Better Than Strings

字符串是一种简单的数据结构,在传递过程中会经过多次重复处理。它是隐藏信息的完美载体。

The string is a stark data structure and everywhere it is passed there is much duplication of process. It is a perfect vehicle for hiding information.

—艾伦·佩利斯

—Alan Perlis

回想起来,我们希望Item早点创建这个类型,可能是在我们提取的时候UserRequestListener,而不是仅仅使用一个String来表示狙击手竞标的东西。如果我们这样做了,我们就可以把止损价添加到现有的Item类中,并且按照定义,它会被传递到需要它的地方。

Looking back, we wish we’d created the Item type earlier, probably when we extracted UserRequestListener, instead of just using a String to represent the thing a Sniper bids for. Had we done so, we could have added the stop price to the existing Item class, and it would have been delivered, by definition, to where it was needed.

我们可能早就注意到,我们不想根据商品标识符对表格进行索引,而是根据Item,这将为在一次拍卖中尝试多种策略提供可能性。我们并不是说我们应该针对尚未证实的需求进行更具推测性的设计。相反,当我们费尽心思清楚地表达领域时,我们经常会发现我们有更多选择。

We might also have noticed sooner that we do not want to index our table on item identifier but on an Item, which would open up the possibility of trying multiple policies in a single auction. We’re not saying that we should have designed more speculatively for a need that hasn’t been proved. Rather, when we take the trouble to express the domain clearly, we often find that we have more options.

定义域类型不仅包装Strings,还包装其他内置类型(包括集合)通常更好。我们所要做的就是记住应用我们自己的建议。如您所见,有时我们会忘记。

It’s often better to define domain types to wrap not only Strings but other built-in types too, including collections. All we have to do is remember to apply our own advice. As you see, sometimes we forget.

第 19 章 处理失败

Chapter 19. Handling Failure

我们在此解决不完美世界中编程的现实问题,并添加故障报告。我们添加了一个报告故障的新拍卖事件。我们附加了一个新的事件监听器,如果狙击枪失败,它将关闭它。我们还将一条消息写入日志并编写一个模拟类的单元测试,对此我们深表歉意。

为了避免进一步考验您的耐心,我们在此结束示例。

In which we address the reality of programming in an imperfect world, and add failure reporting. We add a new auction event that reports failure. We attach a new event listener that will turn off the Sniper if it fails. We also write a message to a log and write a unit test that mocks a class, for which we’re very sorry.

To avoid trying your patience any further, we close our example here.

到目前为止,我们已经准备好假设一切都正常。如果应用程序不需要持续运行,这可能是合理的——如果它只是崩溃了,我们重新启动它,或者像在这种情况下一样,我们主要关注演示和探索领域,这可能是可以接受的。现在是时候开始明确我们如何处理故障了。

So far, we’ve been prepared to assume that everything just works. This might be reasonable if the application is not supposed to last—perhaps it’s acceptable if it just crashes and we restart it or, as in this case, we’ve been mainly concerned with demonstrating and exploring the domain. Now it’s time to start being explicit about how we deal with failures.

如果它不起作用怎么办?

What If It Doesn’t Work?

我们的产品人员担心 Southabee's On-Line 有时会出现故障并发送结构错误的消息,因此他们希望我们能够应对。事实证明,我们与之通信的系统实际上是多个拍卖信息源的聚合器,因此单个拍卖的失败并不意味着整个系统不安全。我们的政策是,当我们收到无法解释的消息时,我们会将该拍卖标记为Failed并忽略任何进一步的更新,因为这意味着我们无法再确定发生了什么。一旦拍卖失败,我们就不会尝试恢复。1

Our product people are concerned that Southabee’s On-Line has a reputation for occasionally failing and sending incorrectly structured messages, so they want us to show that we can cope. It turns out that the system we talk to is actually an aggregator for multiple auction feeds, so the failure of an individual auction does not imply that the whole system is unsafe. Our policy will be that when we receive a message that we cannot interpret, we will mark that auction as Failed and ignore any further updates, since it means we can no longer be sure what’s happening. Once an auction has failed, we make no attempt to recover.1

1 . 我们承认,一个经常篡改信息的拍卖网站不太可能长期存在,但这是一个简单的例子。我们还怀疑任何认真的竞标者会乐意让他们的出价悬而未决,不知道他们是否买到了东西或输给了竞争对手。另一方面,我们看到世界上一些不太可信的系统取得了成功,这些系统由一群特殊处理人员支撑,所以也许你可以让我们逃脱这个惩罚。

1. We admit that it’s unlikely that an auction site that regularly garbles its messages will survive for long, but it’s a simple example to work through. We also doubt that any serious bidder will be happy to let their bid lie hanging, not knowing whether they’ve bought something or lost to a rival. On the other hand, we’ve seen less plausible systems succeed in the world, propped up by an army of special handling, so perhaps you can let us get away with this one.

实际上,报告消息失败意味着我们会刷新价格和出价,并显示有问题商品的状态Failed。我们还会将事件记录在某处,以便稍后处理。我们可以使失败的显示更加明显,例如通过为行着色,但我们将保持此版本简单,并将任何额外内容留给读者作为练习。

In practice, reporting a message failure means that we flush the price and bid values, and show the status as Failed for the offending item. We also record the event somewhere so that we can deal with it later. We could make the display of the failure more obvious, for example by coloring the row, but we’ll keep this version simple and leave any extras as an exercise for the reader.

端到端测试表明,正常工作的 Sniper 会收到错误消息、显示并记录故障,然后忽略此拍卖的进一步更新:

The end-to-end test shows that a working Sniper receives a bad message, displays and records the failure, and then ignores further updates from this auction:

@Test public void

sniperReportsInvalidAuctionMessageAndStopsRespondingToEvents()

throws Exception

{

String brokenMessage = "a broken message";

auction.startSellingItem();

auction2.startSellingItem();



application.startBiddingIn(auction, auction2);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID); auction.reportPrice



(500, 20, "其他竞标者"); auction.hasReceivedBid

(520, ApplicationRunner.SNIPER_XMPP_ID);



auction.sendInvalidMessageContaining (brokenMessage); application.showsSniperHasFailed (auction); auction.reportPrice(520, 21, "其他竞标者"); waitForAnotherAuctionEvent (); application.reportsInvalidMessage ( auction, brokenMessage); application.showsSniperHasFailed (auction); } private void waitForAnotherAuctionEvent()抛出异常 { auction2.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID); auction2.reportPrice(600, 6, "其他竞标者"); application.hasShownSniperIsBidding(auction2, 600, 606); }



























@Test public void

sniperReportsInvalidAuctionMessageAndStopsRespondingToEvents()

throws Exception

{

String brokenMessage = "a broken message";

auction.startSellingItem();

auction2.startSellingItem();



application.startBiddingIn(auction, auction2);

auction.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);



auction.reportPrice(500, 20, "other bidder");

auction.hasReceivedBid(520, ApplicationRunner.SNIPER_XMPP_ID);



auction.sendInvalidMessageContaining(brokenMessage);

application.showsSniperHasFailed(auction);



auction.reportPrice(520, 21, "other bidder");

waitForAnotherAuctionEvent();



application.reportsInvalidMessage(auction, brokenMessage);

application.showsSniperHasFailed(auction);

}



private void waitForAnotherAuctionEvent() throws Exception {

auction2.hasReceivedJoinRequestFrom(ApplicationRunner.SNIPER_XMPP_ID);

auction2.reportPrice(600, 6, "other bidder");

application.hasShownSniperIsBidding(auction2, 600, 606);

}

sendInvalidMessageContaining()通过聊天将给定的无效字符串发送给狙击手,并showsSniperHasFailed()检查物品的状态是否为,以及价格值是否已归零。我们暂时Failed搁置的实现;我们将在本章后面再讨论它。reportsInvalidMessage()

where sendInvalidMessageContaining() sends the given invalid string via a chat to the Sniper, and showsSniperHasFailed() checks that the status for the item is Failed and that the price values have been zeroed. We park the implementation of reportsInvalidMessage() for the moment; we’ll come back to it later in this chapter.

测试是否不会发生某些事情

Testing That Something Doesn’t Happen

图像

您可能已经注意到该waitForAnotherAuctionEvent()方法强制执行不相关的 Sniper 事件,然后等待它通过系统工作。如果没有此调用,最终showSniperHasFailed()检查可能会错误通过,因为它会在系统有时间处理相关价格事件之前拾取之前的 Sniper 状态。附加事件将测试延迟足够长的时间,以确保系统赶上进度。有关异步测试的更多信息,请参阅第 27 章。

You’ll have noticed the waitForAnotherAuctionEvent() method which forces an unrelated Sniper event and then waits for it to work through the system. Without this call, it would be possible for the final showSniperHasFailed() check to pass incorrectly because it would pick up the previous Sniper state—before the system has had time to process the relevant price event. The additional event holds back the test just long enough to make sure that the system has caught up. See Chapter 27 for more on testing with asynchrony.

为了让这个测试适当地失败,我们FAILED向枚举添加一个值SniperState,并在 中添加一个关联的文本映射SnipersTabelModel。测试失败:

To get this test to fail appropriately, we add a FAILED value to the SniperState enumeration, with an associated text mapping in SnipersTabelModel. The test fails:

[...]但是...

它不是带有单元格的表格

<带有文本“item-54321”>,<带有文本“0”>,

<带有文本“0”>,<带有文本“Failed”>

因为

在第 0 行:组件 1 文本为“500”

在第 1 行:组件 0 文本为“item-65432”

[...] but...

it is not table with row with cells

<label with text "item-54321">, <label with text "0">,

<label with text "0">, <label with text "Failed">

because

in row 0: component 1 text was "500"

in row 1: component 0 text was "item-65432"

它显示表中有两行:第二行是另一场拍卖,第一行显示当前价格为 500,而它应该被刷新为 0。这次失败是我们下一步需要构建的标记。

It shows that there are two rows in the table: the second is for the other auction, and the first is showing that the current price is 500 when it should have been flushed to 0. This failure is our marker for what we need to build next.

检测故障

Detecting the Failure

失败实际上会发生在AuctionMessageTranslator(最后在第 14 章中显示)中,当它尝试解析消息时,它将抛出运行时异常。Smack 库会丢弃MessageHandlers 抛出的异常,因此我们必须确保我们的处理程序能够捕获所有内容。当我们为翻译器中的故障编写单元测试时,我们意识到我们需要报告一种新型拍卖事件,因此我们auctionFailed()AuctionEventListener接口添加了一个方法。

The failure will actually occur in the AuctionMessageTranslator (last shown in Chapter 14) which will throw a runtime exception when it tries to parse the message. The Smack library drops exceptions thrown by MessageHandlers, so we have to make sure that our handler catches everything. As we write a unit test for a failure in the translator, we realize that we need to report a new type of auction event, so we add an auctionFailed() method to the AuctionEventListener interface.

@Test public void

notifiesAuctionFailedWhenBadMessageReceived() {

context.checking(new Expectations() {{

exact(1).of(listener). auctionFailed();

}});



Message message = new Message();

message.setBody("一条坏消息");



translator.processMessage(UNUSED_CHAT, message);

}

@Test public void

notifiesAuctionFailedWhenBadMessageReceived() {

context.checking(new Expectations() {{

exactly(1).of(listener).auctionFailed();

}});



Message message = new Message();

message.setBody("a bad message");



translator.processMessage(UNUSED_CHAT, message);

}

ArrayIndexOutOfBoundsException当它尝试从字符串中解包名称/值对时,会失败并出现错误。我们可以精确地确定要捕获哪些异常,但实际上这并不重要:我们要么解析消息,要么不解析,因此为了让测试通过,我们将大部分提取processMessage()到一个translate()方法中,并将try/catch其包装在一个块中。

This fails with an ArrayIndexOutOfBoundsException when it tries to unpack a name/value pair from the string. We could be precise about which exceptions to catch but in practice it doesn’t really matter here: we either parse the message or we don’t, so to make the test pass we extract the bulk of processMessage() into a translate() method and wrap a try/catch block around it.

公共类 AuctionMessageTranslator 实现 MessageListener {

public void processMessage (Chat chat, Message message) {

try {

Translation(message.getBody());

} catch (Exception parseException) {

listener. auctionFailed() ;

}

}

public class AuctionMessageTranslator implements MessageListener {

public void processMessage(Chat chat, Message message) {

try {

translate(message.getBody());

} catch (Exception parseException) {

listener.auctionFailed();

}

}

既然我们在这里,还有另一种故障模式我们想检查一下。有可能一条消息格式正确但不完整:它可能缺少某个字段例如事件类型或当前价格。我们编写了几个测试来确认我们可以捕获这些,例如:

While we’re here, there’s another failure mode we’d like to check. It’s possible that a message is well-formed but incomplete: it might be missing one of its fields such as the event type or current price. We write a couple of tests to confirm that we can catch these, for example:

@Test public void

notifiesAuctionFailedWhenEventTypeMissing () {

context.checking(new Expectations() {{

exact(1).of(listener).auctionFailed();

}});

Message message = new Message();

message.setBody("SOLVersion: 1.1; CurrentPrice: 234; 增量:5; 投标人:"

+ SNIPER_ID + ";");

translator.processMessage(UNUSED_CHAT, message);

}

@Test public void

notifiesAuctionFailedWhenEventTypeMissing () {

context.checking(new Expectations() {{

exactly(1).of(listener).auctionFailed();

}});

Message message = new Message();

message.setBody("SOLVersion: 1.1; CurrentPrice: 234; Increment: 5; Bidder: "

+ SNIPER_ID + ";");

translator.processMessage(UNUSED_CHAT, message);

}

我们的解决方法是,每当我们尝试获取尚未设置的值时抛出异常,我们MissingValueException为此目的进行定义。

Our fix is to throw an exception whenever we try to get a value that has not been set, and we define MissingValueException for this purpose.

公共静态类 AuctionEvent { [...]

私有 String get(String name) 抛出 MissingValueException {

String value = values.get(name);

if (null == value) {

抛出新的 MissingValueException(name);

}

返回值;

}

}

public static class AuctionEvent { [...]

private String get(String name) throws MissingValueException {

String value = values.get(name);

if (null == value) {

throw new MissingValueException(name);

}

return value;

}

}

显示失败

Displaying the Failure

我们在 unit-testing 中添加了一个auctionFailed()方法。这会在 中触发编译器警告,因此我们添加了一个空实现以继续运行。现在是时候让它工作了,这其实很容易。我们在AuctionEventListenerAuctionMessageTranslatorAuctionSniper

We added an auctionFailed() method to AuctionEventListener while unit-testing AuctionMessageTranslator. This triggers a compiler warning in AuctionSniper, so we added an empty implementation to keep going. Now it’s time to make it work, which turns out to be easy. We write some tests in

@Test public void

reportsFailedIfAuctionFailsWhenBidding() {

ignoringAuction();

allowingSniperBidding();



expectSniperToFailWhenItIs("bidding");



sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);

sniper.auctionFailed();

}



private void expectSniperToFailWhenItIs(final String state) {

context.checking(new Expectations() {{

atLeast(1).of(sniperListener).sniperStateChanged(

new SniperSnapshot(ITEM_ID, 00, 0, SniperState.FAILED));

when(sniperState.is(state));

}});

}

@Test public void

reportsFailedIfAuctionFailsWhenBidding() {

ignoringAuction();

allowingSniperBidding();



expectSniperToFailWhenItIs("bidding");



sniper.currentPrice(123, 45, PriceSource.FromOtherBidder);

sniper.auctionFailed();

}



private void expectSniperToFailWhenItIs(final String state) {

context.checking(new Expectations() {{

atLeast(1).of(sniperListener).sniperStateChanged(

new SniperSnapshot(ITEM_ID, 00, 0, SniperState.FAILED));

when(sniperState.is(state));

}});

}

我们添加了一些辅助方法:ignoringAuction()表示我们不关心发生了什么auction,允许事件通过,以便我们能够发现失败;并且expectSniperToFailWhenItIs()描述失败应该是什么样子,包括狙击手的先前状态。

We’ve added a couple more helper methods: ignoringAuction() says that we don’t care what happens to auction, allowing events to pass through so we can get to the failure; and, expectSniperToFailWhenItIs() describes what a failure should look like, including the previous state of the Sniper.

我们要做的就是添加一个failed()转换SniperSnapshot并在新方法中使用它。

All we have to do is add a failed() transition to SniperSnapshot and use it in the new method.

公共类 AuctionSniper 实现 AuctionEventListener {

公共 void auctionFailed() {

快照 =快照.failed() ;

听众.announce().sniperStateChanged(快照);

} [...]



公共类 SniperSnapshot {

公共 SniperSnapshot failed() {

返回新的 SniperSnapshot(itemId, 0, 0, SniperState.FAILED);

} [...]

public class AuctionSniper implements AuctionEventListener {

public void auctionFailed() {

snapshot = snapshot.failed();

listeners.announce().sniperStateChanged(snapshot);

} [...]



public class SniperSnapshot {

public SniperSnapshot failed() {

return new SniperSnapshot(itemId, 0, 0, SniperState.FAILED);

} [...]

这将显示失败,如图19.1所示。

This displays the failure, as we can see in Figure 19.1.

图 19.1 狙击手显示拍卖失败

Figure 19.1 The Sniper shows a failed auction

图像

然而,端到端测试仍然失败。我们添加的同步钩子表明我们还没有断开狙击手从拍卖中接收更多事件的连接。

The end-to-end test, however, still fails. The synchronization hook we added reveals that we haven’t disconnected the Sniper from receiving further events from the auction.

断开狙击手

Disconnecting the Sniper

AuctionMessageTranslator我们通过从其 的Chat集合中移除 Sniper 来关闭它MessageListener。我们可以在处理消息时安全地执行此操作,因为Chat将其侦听器存储在线程安全的“写时复制”集合中。执行此操作的一个明显位置是在 中processMessage()AuctionMessageTranslator它接收Chat作为参数,但我们对此有两点疑虑。首先,正如我们在第 12 章中指出的那样,构造一个 realChat很痛苦。大多数模拟框架都支持创建模拟类,但这让我们感到不舒服,因为这样我们就可以定义与实现的关系,而不是角色——我们对依赖关系过于精确。其次,我们可能为 分配了太多职责AuctionMessageTranslator;它必须翻译消息决定在失败时该做什么。

We turn off a Sniper by removing its AuctionMessageTranslator from its Chat’s set of MessageListeners. We can do this safely while processing a message because Chat stores its listeners in a thread-safe “copy on write” collection. One obvious place to do this is within processMessage() in AuctionMessageTranslator, which receives the Chat as an argument, but we have two doubts about this. First, as we pointed out in Chapter 12, constructing a real Chat is painful. Most of the mocking frameworks support creating a mock class, but it makes us uncomfortable because then we’re defining a relationship with an implementation, not a role—we’re being too precise about our dependencies. Second, we might be assigning too many responsibilities to AuctionMessageTranslator; it would have to translate the message and decide what to do when it fails.

我们的替代方法是将另一个对象附加到实现此断开连接策略的转换器上,使用我们已经拥有的通知基础设施AuctionEventListener

Our alternative approach is to attach another object to the translator that implements this disconnection policy, using the infrastructure we already have for notifying AuctionEventListeners.

公共最终类 XMPPAuction 实现拍卖 {

公共 XMPPAuction(XMPPConnection 连接,字符串 auctionJID){

AuctionMessageTranslator翻译器 = translatorFor(连接);

this.chat = connection.getChatManager().createChat(auctionJID,翻译器);

addAuctionEventListener(chatDisconnectorFor(翻译器));

}



私有 AuctionMessageTranslator translatorFor(XMPPConnection 连接){

返回新的 AuctionMessageTranslator(connection.getUser(,

auctionEventListeners.announce());

}

public final class XMPPAuction implements Auction {

public XMPPAuction(XMPPConnection connection, String auctionJID) {

AuctionMessageTranslator translator = translatorFor(connection);

this.chat = connection.getChatManager().createChat(auctionJID, translator);

addAuctionEventListener(chatDisconnectorFor(translator));

}



private AuctionMessageTranslator translatorFor(XMPPConnection connection) {

return new AuctionMessageTranslator(connection.getUser(),

auctionEventListeners.announce());

}

z

private AuctionEventListener

chatDisconnectorFor(final AuctionMessageTranslator translator) {

return new AuctionEventListener() {

public void auctionFailed () {

chat.removeMessageListener(translator);

}

public void auctionClosed( // 空方法

public void currentPrice( // 空方法

};

} [...]

private AuctionEventListener

chatDisconnectorFor(final AuctionMessageTranslator translator) {

return new AuctionEventListener() {

public void auctionFailed() {

chat.removeMessageListener(translator);

}

public void auctionClosed(// empty method

public void currentPrice( // empty method

};

} [...]

就目前情况而言,端到端测试已通过。

The end-to-end test, as far as it goes, passes.

组合贝壳游戏

The Composition Shell Game

图像

这次设计中的问题不是功能的根本复杂性(这是不变的),而是我们如何划分它。我们选择的设计(附加断开连接监听器)可以说比它的替代方案(分离翻译器中的聊天)更复杂。它当然需要更多行代码,但这不是唯一的衡量标准。相反,我们强调“单一责任”原则,这意味着每个对象只做好一件事,系统行为来自我们如何组装这些对象。

The issue in this design episode is not the fundamental complexity of the feature, which is constant, but how we divide it up. The design we chose (attaching a disconnection listener) could be argued to be more complicated than its alternative (detaching the chat within the translator). It certainly takes more lines of code, but that’s not the only metric. Instead, we’re emphasizing the “single responsibility” principle, which means each object does just one thing well and the system behavior comes from how we assemble those objects.

有时感觉我们寻找的行为总是在别处(正如 Gertrude Stein 所说,“那里没有那里”),这可能会让不习惯这种风格的开发人员感到沮丧。另一方面,我们的经验是,集中职责使代码更易于维护,因为我们不必绕过不相关的功能来获得我们需要的部分。有关详细讨论,请参阅第 6 章。

Sometimes this feels as if the behavior we’re looking for is always somewhere else (as Gertrude Stein said, “There is no there there”), which can be frustrating for developers not used to the style. Our experience, on the other hand, is that focused responsibilities make the code more maintainable because we don’t have to cut through unrelated functionality to get to the piece we need. See Chapter 6 for a longer discussion.

记录失败

Recording the Failure

现在我们要回到端到端测试和reportsInvalidMessage()我们停止的方法。我们的要求是 Sniper 应用程序必须记录有关这些故障的消息,以便用户的组织可以恢复这种情况。这意味着我们的测试应该查找日志文件并检查其内容。

Now we want to return to the end-to-end test and the reportsInvalidMessage() method that we parked. Our requirement is that the Sniper application must log a message about these failures so that the user’s organization can recover the situation. This means that our test should look for a log file and check its contents.

填写测试

Filling In the Test

我们在每次测试之前实施缺失检查并刷新日志,将日志文件的管理委托给AuctionLogDriver使用 Apache Commons IO 库的类。它还通过重置日志管理器(我们实际上不应该在同一个地址空间中)稍微作弊,因为删除日志文件可能会混淆缓存的记录器。

We implement the missing check and flush the log before each test, delegating the management of the log file to an AuctionLogDriver class which uses the Apache Commons IO library. It also cheats slightly by resetting the log manager (we’re not really supposed to be in the same address space), since deleting the log file can confuse a cached logger.

公共类 ApplicationRunner { [...]

private AuctionLogDriver logDriver = new AuctionLogDriver();



public void reportsInvalidMessage(FakeAuctionServer auction, String message)

throws IOException

{

logDriver .hasEntry(containsString(message));

}



public void startBiddingWithStopPrice (FakeAuctionServer auction, int stopPrice) {

startSniper();

openBiddingFor(auction, stopPrice);

}

private startSniper() {

logDriver.clearLog()

Thread thread = new Thread("测试应用程序") {

@Override public void run() { // 启动应用程序 [...]

}

}



public class AuctionLogDriver {

public static final String LOG_FILE_NAME = "auction-sniper.log";

private final File logFile = new File(LOG_FILE_NAME);



公共 void hasEntry(Matcher<String> matcher) 抛出 IOException {

assertThat(FileUtils.readFileToString(logFile), matcher);

}

公共 void clearLog() {

logFile.delete();

LogManager.getLogManager().reset();

}

}

public class ApplicationRunner { [...]

private AuctionLogDriver logDriver = new AuctionLogDriver();



public void reportsInvalidMessage(FakeAuctionServer auction, String message)

throws IOException

{

logDriver.hasEntry(containsString(message));

}



public void startBiddingWithStopPrice(FakeAuctionServer auction, int stopPrice) {

startSniper();

openBiddingFor(auction, stopPrice);

}

private startSniper() {

logDriver.clearLog()

Thread thread = new Thread("Test Application") {

@Override public void run() { // Start the application [...]

}

}



public class AuctionLogDriver {

public static final String LOG_FILE_NAME = "auction-sniper.log";

private final File logFile = new File(LOG_FILE_NAME);



public void hasEntry(Matcher<String> matcher) throws IOException {

assertThat(FileUtils.readFileToString(logFile), matcher);

}

public void clearLog() {

logFile.delete();

LogManager.getLogManager().reset();

}

}

这项新检查只是让我们确信我们已经将消息输入系统并输入到某种日志记录中 — 它告诉我们各个部分是匹配的。稍后我们将对日志记录的内容进行更全面的测试。端到端测试现在失败了,因为当然没有日志文件可以读取。

This new check only reassures us that we’ve fed a message through the system and into some kind of log record—it tells us that the pieces fit together. We’ll write a more thorough test of the contents of a log record later. The end-to-end test now fails because, of course, there’s no log file to read.

转换器中的故障报告

Failure Reporting in the Translator

再次强调,第一个变化是在 中AuctionMessageTranslator。我们希望记录包含拍卖标识符、收到的消息和抛出的异常。“单一责任”原则表明AuctionMessageTranslator不应负责决定如何报告事件,因此我们发明了一个新的协作者来处理此任务。我们称之为XMPPFailureReporter

Once again, the first change is in the AuctionMessageTranslator. We’d like the record to include the auction identifier, the received message, and the thrown exception. The “single responsibility” principle suggests that the AuctionMessageTranslator should not be responsible for deciding how to report the event, so we invent a new collaborator to handle this task. We call it XMPPFailureReporter:

公共接口 XMPPFailureReporter {

void couldTranslateMessage(String auctionId, String failedMessage,

Exception exception);

}

public interface XMPPFailureReporter {

void cannotTranslateMessage(String auctionId, String failedMessage,

Exception exception);

}

我们修改了现有的失败测试,​​在辅助方法中包含了消息创建和常见期望,例如:

We amend our existing failure tests, wrapping up message creation and common expectations in helper methods, for example:

@Test public void

notifiesAuctionFailedWhenBadMessageReceived() {

String badMessage = "一条错误消息";

expectFailureWithMessage(badMessage);

translator.processMessage(UNUSED_CHAT, message(badMessage));

}

private Message message(String body) {

Message message = new Message();

message.setBody(body);

返回消息;

}

private void expectFailureWithMessage(final String badMessage) {

context.checking(new Expectations() {{

oneOf(listener).auctionFailed();

oneOf(failureReporter).cannotTranslateMessage(

with(SNIPER_ID), with(badMessage),

with(any(Exception.class)));

}});

}

@Test public void

notifiesAuctionFailedWhenBadMessageReceived() {

String badMessage = "a bad message";

expectFailureWithMessage(badMessage);

translator.processMessage(UNUSED_CHAT, message(badMessage));

}

private Message message(String body) {

Message message = new Message();

message.setBody(body);

return message;

}

private void expectFailureWithMessage(final String badMessage) {

context.checking(new Expectations() {{

oneOf(listener).auctionFailed();

oneOf(failureReporter).cannotTranslateMessage(

with(SNIPER_ID), with(badMessage),

with(any(Exception.class)));

}});

}

新的报告器是翻译器的依赖项,因此我们通过构造函数将其输入,并在通知任何侦听器之前调用它。我们知道它message.getBody()不会引发异常,它只是一个简单的 bean,所以我们可以将其留在 catch 块之外。

The new reporter is a dependency for the translator, so we feed it in through the constructor and call it just before notifying any listeners. We know that message.getBody() will not throw an exception, it’s just a simple bean, so we can leave it outside the catch block.

公共类AuctionMessageTranslator实现MessageListener {

public void processMessage(Chat chat, Message message) {

String messageBody = message.getBody();

尝试{

翻译(messageBody);

} catch (RuntimeException 异常) {

FailureReporter.cannotTranslateMessage(sniperId, messageBody, 异常);

listener.auctionFailed();

}

} [...]

public class AuctionMessageTranslator implements MessageListener {

public void processMessage(Chat chat, Message message) {

String messageBody = message.getBody();

try {

translate(messageBody);

} catch (RuntimeException exception) {

failureReporter.cannotTranslateMessage(sniperId, messageBody, exception);

listener.auctionFailed();

}

} [...]

单元测试通过。

The unit test passes.

生成日志消息

Generating the Log Message

下一阶段是使用XMPPFailureReporter生成日志文件的程序来实现。在这里我们实际上检查日志条目的格式和内容。我们启动一个类LoggingXMPPFailureReporter并决定使用 Java 的内置日志框架。我们可以让这个新类的测试从真实文件中写入和读取。相反,我们认为文件访问已被我们刚刚设置的端到端测试充分覆盖,因此我们将在内存中运行所有内容以减少测试的依赖性。我们相信我们可以走这条捷径,因为这个例子非常简单;对于更复杂的行为,我们会编写一些集成测试。

The next stage is to implement the XMPPFailureReporter with something that generates a log file. This is where we actually check the format and contents of a log entry. We start a class LoggingXMPPFailureReporter and decide to use Java’s built-in logging framework. We could make the tests for this new class write and read from a real file. Instead, we decide that file access is sufficiently covered by the end-to-end test we’ve just set up, so we’ll run everything in memory to reduce the test’s dependencies. We’re confident we can take this shortcut, because the example is so simple; for more complex behavior we would write some integration tests.

Java 日志框架没有接口,因此我们必须比我们想要的更具体。我们决定使用基于类的模拟来覆盖 中的相关方法Logger;在 jMock 中,我们通过调用启用基于类的模拟setImposteriser()。注释AfterClass告诉 JUnit 在所有测试运行后调用resetLogging(),以清除我们可能对日志环境所做的任何更改。

The Java logging framework has no interfaces, so we have to be more concrete than we’d like. Exceptionally, we decide to use a class-based mock to override the relevant method in Logger; in jMock we turn on class-based mocking with the setImposteriser() call. The AfterClass annotation tells JUnit to call resetLogging() after all the tests have run to flush any changes we might have made to the logging environment.

@RunWith(JMock.class)

public class LoggingXMPPFailureReporterTest {

private final Mockery context = new Mockery() {{

setImposteriser(ClassImposteriser.INSTANCE);

};

final Logger logger = context.mock(Logger.class);

final LoggingXMPPFailureReporter reporter = new LoggingXMPPFailureReporter(logger);



@AfterClass

public static void resetLogging() {

LogManager.getLogManager().reset();

}



@Test public void

writesMessageTranslationFailureToLog() {

context.checking(new Expectations() {{

oneOf(logger). serious ("<auction id> "

+ "无法翻译消息 \"bad message\" "

+ "因为 \"java.lang.Exception: bad\"");

}});

reporter.cannotTranslateMessage("auction id", "bad message", new Exception("bad"));

}

}

@RunWith(JMock.class)

public class LoggingXMPPFailureReporterTest {

private final Mockery context = new Mockery() {{

setImposteriser(ClassImposteriser.INSTANCE);

}};

final Logger logger = context.mock(Logger.class);

final LoggingXMPPFailureReporter reporter = new LoggingXMPPFailureReporter(logger);



@AfterClass

public static void resetLogging() {

LogManager.getLogManager().reset();

}



@Test public void

writesMessageTranslationFailureToLog() {

context.checking(new Expectations() {{

oneOf(logger).severe("<auction id> "

+ "Could not translate message \"bad message\" "

+ "because \"java.lang.Exception: bad\"");

}});

reporter.cannotTranslateMessage("auction id", "bad message", new Exception("bad"));

}

}

我们通过一个实现来通过此测试,该实现仅使用从输入格式化的字符串来调用记录器cannotTranslateMessage()

We pass this test with an implementation that just calls the logger with a string formatted from the inputs to cannotTranslateMessage().

违反我们自己的规则?

Breaking Our Own Rules?

图像

我们已经写过我们不喜欢模拟类,并且我们将在第 20 章中进一步讨论它。那么,我们为什么在这里这样做呢?

We already wrote that we don’t like to mock classes, and we go on about it further in Chapter 20. So, how come we’re doing it here?

我们在此测试中关心的是将值呈现为具有严重性级别的失败消息。该类非常有限,只是日志记录层之上的垫片,因此我们认为不值得引入另一个间接级别来定义日志记录角色。正如我们之前所写,我们也不认为值得针对真实文件运行它,因为这会引入与我们正在开发的功能无关的依赖关系(甚至更糟的是异步)。我们还认为,作为 Java 运行时的一部分,日志记录 API 不太可能发生变化。

What we care about in this test is the rendering of the values into a failure message with a severity level. The class is very limited, just a shim above the logging layer, so we don’t think it’s worth introducing another level of indirection to define the logging role. As we wrote before, we also don’t think it worth running against a real file since that introduces dependencies (and, even worse, asynchrony) not really relevant to the functionality we’re developing. We also believe that, as part of the Java runtime, the logging API is unlikely to change.

因此,仅此一次,作为一项特殊恩惠,我们不设先例,也不做任何承诺,而是模拟该类Logger。在我们继续之前,还有几点值得一提。首先,我们不会对我们代码内部的类执行此操作,因为这样我们就可以编写一个接口来描述它所扮演的角色。其次,如果LoggingXMPPFailureReporter复杂性增加,我们可能会发现自己发现了一个可以直接测试的支持消息格式化程序类。

So, just this once, as a special favor, setting no precedents, making no promises, we mock the Logger class. There are a couple more points worth making before we move on. First, we would not do this for a class that is internal to our code, because then we would be able write an interface to describe the role it’s playing. Second, if the LoggingXMPPFailureReporter were to grow in complexity, we would probably find ourselves discovering a supporting message formatter class that could be tested directly.

闭环

Closing the Loop

现在我们已经准备好了使整个端到端测试通过的所有部分。我们将 的一个实例插入LoggingXMPPFailureReporterXMPPAuctionHouse以便通过其XMPPAuctions ,AuctionMessageTranslator使用报告器构建 every 。我们还将定义日志文件名的常量从 移到AuctionLogDriver,并定义一个新的XMPPAuctionException来收集包内的任何故障。

Now we have the pieces in place to make the whole end-to-end test pass. We plug an instance of the LoggingXMPPFailureReporter into the XMPPAuctionHouse so that, via its XMPPAuctions, every AuctionMessageTranslator is constructed with the reporter. We also move the constant that defines the log file name there from AuctionLogDriver, and define a new XMPPAuctionException to gather up any failures within the package.

公共类 XMPPAuctionHouse 实现 AuctionHouse {

公共 XMPPAuctionHouse(XMPPConnection 连接)

抛出 XMPPAuctionException

{

this.connection = 连接;

this.failureReporter = 新 LoggingXMPPFailureReporter(makeLogger());

}

公共拍卖 auctionFor(String itemId){

返回新 XMPPAuction(连接,拍卖Id(itemId,连接),failureReporter);

}

私有 Logger makeLogger()抛出 XMPPAuctionException {

Logger logger = Logger.getLogger(LOGGER_NAME);

logger.setUseParentHandlers(false);

logger.addHandler(simpleFileHandler());

返回 logger;

}

私有 FileHandler simpleFileHandler()抛出 XMPPAuctionException {

尝试 {

FileHandler handler = new FileHandler(LOG_FILE_NAME);

handler.setFormatter(new SimpleFormatter());

返回处理程序;

} catch (Exception e) {

抛出新的 XMPPAuctionException("无法创建记录器 FileHandler "

+ getFullPath(LOG_FILE_NAME), e);

}

} [...]

public class XMPPAuctionHouse implements AuctionHouse {

public XMPPAuctionHouse(XMPPConnection connection)

throws XMPPAuctionException

{

this.connection = connection;

this.failureReporter = new LoggingXMPPFailureReporter(makeLogger());

}

public Auction auctionFor(String itemId) {

return new XMPPAuction(connection, auctionId(itemId, connection), failureReporter);

}

private Logger makeLogger() throws XMPPAuctionException {

Logger logger = Logger.getLogger(LOGGER_NAME);

logger.setUseParentHandlers(false);

logger.addHandler(simpleFileHandler());

return logger;

}

private FileHandler simpleFileHandler() throws XMPPAuctionException {

try {

FileHandler handler = new FileHandler(LOG_FILE_NAME);

handler.setFormatter(new SimpleFormatter());

return handler;

} catch (Exception e) {

throw new XMPPAuctionException("Could not create logger FileHandler "

+ getFullPath(LOG_FILE_NAME), e);

}

} [...]

端到端测试完全通过,我们可以从列表中划掉另一项:图 19.2

The end-to-end test passes completely and we can cross another item off our list: Figure 19.2.

图 19.2 狙击手报告拍卖失败的消息

Figure 19.2 The Sniper reports failed messages from an auction

图像

观察结果

Observations

“逆向萨拉米香肠”开发

“Inverse Salami” Development

我们希望现在您已经了解了软件逐步发展的节奏,即以薄而连贯的部分添加功能。对于每个新功能,编写一些测试来展示它应该做什么,完成每个测试,只更改足够多的代码以使其通过,根据需要重组代码,为新功能腾出空间或揭示新概念 - 然后发布。我们将在第 5 章中讨论这如何融入更大的开发图景。在静态语言(例如 Java 和 C#)中,我们通常可以使用编译器来帮助我们导航实现依赖关系链:更改代码以接受新的触发事件,查看哪些中断,修复该中断,查看该更改依次中断了什么,并重复该过程,直到功能正常运行。

We hope that by now you’re getting a sense of the rhythm of incrementally growing software, adding functionality in thin but coherent slices. For each new feature, write some tests that show what it should do, work through each of those tests changing just enough code to make it pass, restructure the code as needed either to open up space for new functionality or to reveal new concepts—then ship it. We discuss how this fits into the larger development picture in Chapter 5. In static languages, such as Java and C#, we can often use the compiler to help us navigate the chain of implementation dependencies: change the code to accept the new triggering event, see what breaks, fix that breakage, see what that change breaks in turn, and repeat the process until the functionality works.

技巧在于学习如何将需求划分为增量部分,始终让某些功能正常工作,始终只添加一项功能。这个过程应该是无休止的——它只是在不断前进。为了做到这一点,我们必须了解如何逐步更改代码,并且至关重要的是,保持代码结构良好,以便我们可以将其带到我们需要的任何地方(我们还不知道那是哪里)。这就是为什么测试驱动的重构部分开发周期非常关键——如果我们不遵守承诺,总会遇到麻烦。

The skill is in learning how to divide requirements up into incremental slices, always having something working, always adding just one more feature. The process should feel relentless—it just keeps moving. To make this work, we have to understand how to change the code incrementally and, critically, keep the code well structured so that we can take it wherever we need to go (and we don’t know where that is yet). This is why the refactoring part of a test-driven development cycle is so critical—we always get into trouble when we don’t keep up that side of the bargain.

表达意图的小方法

Small Methods to Express Intent

我们习惯于编写辅助方法来包装少量代码 — 原因有二。首先,这减少了 Java 等语言强加给我们的调用代码中的语法噪音。例如,当我们断开狙击手的连接时,该translatorFor()方法意味着我们不必"AuctionMessageTranslator"在同一行中输入两次。其次,这为原本不明显的结构赋予了一个有意义的名称。例如,chatDisconnectorFor()描述其匿名类的作用,并且比定义命名的内部类更不具侵入性。

We have a habit of writing helper methods to wrap up small amounts of code—for two reasons. First, this reduces the amount of syntactic noise in the calling code that languages like Java force upon us. For example, when we disconnect the Sniper, the translatorFor() method means we don’t have to type "AuctionMessageTranslator" twice in the same line. Second, this gives a meaningful name to a structure that would not otherwise be obvious. For example, chatDisconnectorFor() describes what its anonymous class does and is less intrusive than defining a named inner class.

我们的目标是尽我们所能使每一级代码尽可能的易读和不言自明,一直重复这个过程直到我们真正必须使用 Java 构造。

Our aim is to do what we can to make each level of code as readable and self-explanatory as possible, repeating the process all the way down until we actually have to use a Java construct.

日志记录也是一个功能

Logging Is Also a Feature

我们定义了XMPPFailureReporter将故障报告打包起来AuctionMessageTranslator。许多团队会认为这是过度设计,并只是在原地写入日志消息。我们认为,在同一代码中混合级别(消息转换和日志记录)会削弱设计。

We defined XMPPFailureReporter to package up failure reporting for the AuctionMessageTranslator. Many teams would regard this as overdesign and just write the log message in place. We think this would weaken the design by mixing levels (message translation and logging) in the same code.

我们已经看到许多系统,开发人员在需要时会临时添加日志记录。但是,生产日志记录是一个外部接口,应该由依赖它的用户的需求驱动,而不是由当前实现的结构驱动。我们发现,当我们不辞辛劳地用调用者的术语描述运行时报告时,就像我们对所做的那样XMPPFailureReporter,我们最终会得到更有用的日志。我们还发现,我们最终会将日志记录基础结构明确地隔离开来,而不是分散在整个代码中,这使得它更易于使用。

We’ve seen many systems where logging has been added ad hoc by developers wherever they find a need. However, production logging is an external interface that should be driven by the requirements of those who will depend on it, not by the structure of the current implementation. We find that when we take the trouble to describe runtime reporting in the caller’s terms, as we did with the XMPPFailureReporter, we end up with more useful logs. We also find that we end up with the logging infrastructure clearly isolated, rather than scattered throughout the code, which makes it easier to work with.

这个话题是如此令人头疼(至少对史蒂夫来说),以至于我们在第 20 章中用整整一节来讨论它。

This topic is such a bugbear (for Steve at least) that we devote a whole section to it in Chapter 20.

第四部分 可持续的测试驱动开发

Part IV. Sustainable Test-Driven Development

本部分讨论了我们在测试代码中寻找的品质,这些品质使开发“易于上手”。我们希望通过使测试富有表现力来确保测试发挥其作用,这样我们在阅读测试时就能知道哪些是重要的,哪些是失败的,并确保测试本身不会成为维护的负担。我们需要像对待生产代码一样小心和关注测试,尽管编码风格可能有所不同。测试困难可能意味着我们需要更改测试代码,但通常这暗示着我们的设计思路是错误的,我们应该更改生产代码。

This part discusses the qualities we look for in test code that keep the development “habitable.” We want to make sure the tests pull their weight by making them expressive, so that we can tell what’s important when we read them and when they fail, and by making sure they don’t become a maintenance drag themselves. We need to apply as much care and attention to the tests as we do to the production code, although the coding styles may differ. Difficulty in testing might imply that we need to change our test code, but often it’s a hint that our design ideas are wrong and that we ought to change the production code.

我们把这些准则写成了单独的章节,但这更多的是因为我们需要一种适合一本书的线性结构。实际上,这些品质都是相互关联和相互支持的。测试驱动开发将测试、规范和设计结合成一个整体活动。1

We’ve written up these guidelines as separate chapters, but that has more to do with our need for a linear structure that will fit into a book. In practice, these qualities are all related to and support each other. Test-driven development combines testing, specification, and design into one holistic activity.1

1.对我们来说,这种相互关联的一个标志就是我们很难将材料分成连贯的章节。

1. For us, a sign of this interrelatedness was the difficulty we had in breaking up the material into coherent chapters.

第 20 章 聆听测试

Chapter 20. Listening to the Tests

只要观察你就可以看到很多东西。

You can see a lot just by observing.

— 尤吉·贝拉

—Yogi Berra

介绍

Introduction

有时,我们发现很难为想要添加到代码中的某些功能编写测试。根据我们的经验,这通常意味着我们的设计可以改进 — 也许该类与其环境的耦合太紧密,或者没有明确的职责。当这种情况发生时,我们首先检查这是否是改进代码的机会,然后再通过使测试更复杂或使用更复杂的工具来解决设计问题。我们发现,使对象易于测试的品质也会使我们的代码对变化做出响应。

Sometimes we find it difficult to write a test for some functionality we want to add to our code. In our experience, this usually means that our design can be improved—perhaps the class is too tightly coupled to its environment or does not have clear responsibilities. When this happens, we first check whether it’s an opportunity to improve our code, before working around the design by making the test more complicated or using more sophisticated tools. We’ve found that the qualities that make an object easy to test also make our code responsive to change.

诀窍在于让测试驱动我们的设计(这就是它被称为测试驱动开发的原因)。TDD 是关于测试代码,验证其外部可见的质量,例如功能和性能。TDD涉及对代码内部质量的反馈:其类的耦合和内聚性、显式或隐藏的依赖关系以及有效的信息隐藏 - 保持代码可维护的质量。

The trick is to let our tests drive our design (that’s why it’s called test-driven development). TDD is about testing code, verifying its externally visible qualities such as functionality and performance. TDD is also about feedback on the code’s internal qualities: the coupling and cohesion of its classes, dependencies that are explicit or hidden, and effective information hiding—the qualities that keep the code maintainable.

通过实践,我们对测试中的缺陷变得更加敏感,因此我们可以利用这些缺陷快速反馈设计。现在,当我们发现某个功能难以测试时,我们不仅会问自己如何测试,还会问为什么难以测试。

With practice, we’ve become more sensitive to the rough edges in our tests, so we can use them for rapid feedback about the design. Now when we find a feature that’s difficult to test, we don’t just ask ourselves how to test it, but also why is it difficult to test.

在本章中,我们将介绍一些常见的“测试异味”,并讨论它们对代码设计可能意味着什么。有两类测试异味需要考虑。一类是测试本身编写得不好——可能不清楚或很脆弱。Meszaros [Meszaros07]在他的“测试异味”一章中介绍了几种这样的模式。本章关注另一类,即测试强调目标代码是问题所在。Meszaros 针对这种情况提出了一种模式,称为“难以测试的代码”。我们挑选了一些与我们的 TDD 方法相关的常见案例。

In this chapter, we look at some common “test smells” that we’ve encountered and discuss what they might imply about the design of the code. There are two categories of test smell to consider. One is where the test itself is not well written—it may be unclear or brittle. Meszaros [Meszaros07] covers several such patterns in his “Test Smells” chapter. This chapter is concerned with the other category, where a test is highlighting that the target code is the problem. Meszaros has one pattern for this, called “Hard-to-Test Code.” We’ve picked out some common cases that we’ve seen that are relevant to our approach to TDD.

我需要模拟一个无法替换的对象(没有魔法)

I Need to Mock an Object I Can’t Replace (without Magic)

单例是依赖项

Singletons Are Dependencies

降低代码复杂性的一种解释是使常用对象可通过全局结构访问,通常以单例形式实现。任何需要访问某个功能的代码都可以通过其全局名称引用它,而不必将其作为参数接收。这是一个常见的例子:

One interpretation of reducing complexity in code is making commonly useful objects accessible through a global structure, usually implemented as a singleton. Any code that needs access to a feature can just refer to it by its global name instead of receiving it as an argument. Here’s a common example:

现在日期 = 新日期();

Date now = new Date();

在幕后,构造函数调用单例System并使用 将新实例设置为当前时间System.currentTimeMillis()。这是一种方便的技术,但需要付出代价。假设我们要编写这样的测试:

Under the covers, the constructor calls the singleton System and sets the new instance to the current time using System.currentTimeMillis(). This is a convenient technique, but it comes at a cost. Let’s say we want to write a test like this:

@Test public void acceptsRequestsNotWithinTheSameDay() {

receiver.acceptRequest(FIRST_REQUEST);

// 第二天

assertFalse("现在太晚了",receiver.acceptRequest(SECOND_REQUEST));

}

@Test public void rejectsRequestsNotWithinTheSameDay() {

receiver.acceptRequest(FIRST_REQUEST);

// the next day

assertFalse("too late now", receiver.acceptRequest(SECOND_REQUEST));

}

实现如下:

The implementation looks like this:

public boolean acceptRequest(Request request) {

final Date now = new Date();

if (dateOfFirstRequest == null) {

dateOfFirstRequest = now;

} else if (firstDateIsDifferentFrom(now)) {

return false;

}

// 处理请求

return true;

}

public boolean acceptRequest(Request request) {

final Date now = new Date();

if (dateOfFirstRequest == null) {

dateOfFirstRequest = now;

} else if (firstDateIsDifferentFrom(now)) {

return false;

}

// process the request

return true;

}

其中dateOfFirstRequest是一个字段并且firstDateIsDifferentFrom()是一个辅助方法,它隐藏了使用 Java 日期库的不愉快。

where dateOfFirstRequest is a field and firstDateIsDifferentFrom() is a helper method that hides the unpleasantness of working with the Java date library.

要测试此超时,我们必须让测试等待一整夜,或者采取一些巧妙的措施(可能使用方面或字节码操作)来拦截构造函数并返回适合Date测试的值。测试中的这种困难暗示我们应该更改代码。为了使测试更容易,我们需要控制Date对象的创建方式,因此我们引入了Clock并将其传递给Receiver。如果我们存根Clock,测试可能如下所示:

To test this timeout, we must either make the test wait overnight or do something clever (perhaps with aspects or byte-code manipulation) to intercept the constructor and return suitable Date values for the test. This difficulty in testing is a hint that we should change the code. To make the test easier, we need to control how Date objects are created, so we introduce a Clock and pass it into the Receiver. If we stub Clock, the test might look like this:

@Test public void acceptsRequestsNotWithinTheSameDay() {

接收器接收器 = new Receiver(stubClock);

stubClock.setNextDate(TODAY);

接收器.acceptRequest(FIRST_REQUEST);



stubClock.setNextDate(TOMORROW);

assertFalse("现在太晚了", 接收器.acceptRequest(SECOND_REQUEST));

}

@Test public void rejectsRequestsNotWithinTheSameDay() {

Receiver receiver = new Receiver(stubClock);

stubClock.setNextDate(TODAY);

receiver.acceptRequest(FIRST_REQUEST);



stubClock.setNextDate(TOMORROW);

assertFalse("too late now", receiver.acceptRequest(SECOND_REQUEST));

}

实现如下:

and the implementation like this:

public boolean acceptRequest(Request request) {

final Date now = clock.now();

if (dateOfFirstRequest == null) {

dateOfFirstRequest = now;

} else if (firstDateIsDifferentFrom(now)) {

return false;

}

// 处理请求

return true;

}

public boolean acceptRequest(Request request) {

final Date now = clock.now();

if (dateOfFirstRequest == null) {

dateOfFirstRequest = now;

} else if (firstDateIsDifferentFrom(now)) {

return false;

}

// process the request

return true;

}

现在我们可以测试Receiver而不需要任何特殊技巧。然而,更重要的是,我们已经明确了Receiver依赖于时间——如果没有 ,我们甚至无法创建一个Clock。有些人认为这会破坏封装,因为它会暴露 的内部结构Receiver——我们应该能够创建一个实例而不必担心——但我们已经看到很多系统无法测试,因为开发人员没有隔离时间概念。我们知道这种依赖关系,尤其是当服务在全球范围内推出时,纽约和伦敦开始抱怨不同的结果。

Now we can test the Receiver without any special tricks. More importantly, however, we’ve made it obvious that Receiver is dependent on time—we can’t even create one without a Clock. Some argue that this is breaking encapsulation by exposing the internals of a Receiver—we should be able to just create an instance and not worry—but we’ve seen so many systems that are impossible to test because the developers did not isolate the concept of time. We want to know about this dependency, especially when the service is rolled out across the world, and New York and London start complaining about different results.

从过程到对象

From Procedures to Objects

费尽心思引入一个Clock对象后,我们开始怀疑我们的代码是否缺少一个概念:根据我们的领域进行日期检查。AReceiver不需要知道日历系统的所有细节,例如时区和语言环境;它只需要知道此应用程序的日期是否已更改。片段中有一个线索:

Having taken the trouble to introduce a Clock object, we start wondering if our code is missing a concept: date checking in terms of our domain. A Receiver doesn’t need to know all the details of a calendar system, such as time zones and locales; it just need to know if the date has changed for this application. There’s a clue in the fragment:

firstDateIsDifferentFrom(现在)

firstDateIsDifferentFrom(now)

这意味着我们必须在 中包装一些日期操作代码Receiver。这是错误的对象;这种工作应该在 中完成Clock。我们再次编写测试:

which means that we’ve had to wrap up some date manipulation code in Receiver. It’s the wrong object; that kind of work should be done in Clock. We write the test again:

@Test public void acceptsRequestsNotWithinTheSameDay() {

接收器接收器 = 新接收器(clock);

context.checking(new Expectations(){{

allowing(clock).now(); will(returnValue(NOW));

one(clock).dayHasChangedFrom(NOW); will(returnValue(false));

}});



接收器.acceptRequest(FIRST_REQUEST);

assertFalse(“现在太晚了”,接收器.acceptRequest(SECOND_REQUEST));

}

@Test public void rejectsRequestsNotWithinTheSameDay() {

Receiver receiver = new Receiver(clock);

context.checking(new Expectations() {{

allowing(clock).now(); will(returnValue(NOW));

one(clock).dayHasChangedFrom(NOW); will(returnValue(false));

}});



receiver.acceptRequest(FIRST_REQUEST);

assertFalse("too late now", receiver.acceptRequest(SECOND_REQUEST));

}

实现如下:

The implementation looks like this:

public boolean acceptRequest(Request request) {

if (dateOfFirstRequest == null) {

dateOfFirstRequest = clock.now();

} else if ( clock.dayHasChangedFrom(dateOfFirstRequest) ) {

return false;

}

// 处理请求

return true;

}

public boolean acceptRequest(Request request) {

if (dateOfFirstRequest == null) {

dateOfFirstRequest = clock.now();

} else if (clock.dayHasChangedFrom(dateOfFirstRequest)) {

return false;

}

// process the request

return true;

}

此版本的Receiver更加专注:它不需要知道如何区分一个日期与另一个日期,而只需要获取一个日期来设置第一个值。该Clock接口准确地定义了其环境中的那些日期服务Receiver需求。

This version of Receiver is more focused: it doesn’t need to know how to distinguish one date from another and it only needs to get a date to set the first value. The Clock interface defines exactly those date services Receiver needs from its environment.

但我们认为我们可以进一步推动这一点。Receiver只保留一个日期,以便它可以检测到日期的变化;也许我们应该将所有日期功能委托给另一个对象,由于想要一个更好的名字,我们将其称为SameDayChecker

But we think we can push this further. Receiver only retains a date so that it can detect a change of day; perhaps we should delegate all the date functionality to another object which, for want of a better name, we’ll call a SameDayChecker.

@Test public void acceptsRequestsOutsideAllowedPeriod() {

Receiverreceiver = new Receiver(sameDayChecker);

context.checking(new Expectations() {{

allowing(sameDayChecker).hasExpired(); will(returnValue(false));

}});



assertFalse("现在太晚了",receiver.acceptRequest(REQUEST));

}

@Test public void rejectsRequestsOutsideAllowedPeriod() {

Receiver receiver = new Receiver(sameDayChecker);

context.checking(new Expectations() {{

allowing(sameDayChecker).hasExpired(); will(returnValue(false));

}});



assertFalse("too late now", receiver.acceptRequest(REQUEST));

}

实现如下:

with an implementation like this:

public boolean acceptRequest(Request request) {

if ( sameDayChecker.hasExpired() ) {

return false;

}

// 处理请求

return true;

}

public boolean acceptRequest(Request request) {

if (sameDayChecker.hasExpired()) {

return false;

}

// process the request

return true;

}

所有与日期有关的逻辑都已从中分离出来Receiver,这样就可以专注于处理请求。有了两个对象,我们可以确保每个行为(日期检查和请求处理)都经过了干净的单元测试。

All the logic about dates has been separated out from Receiver, which can concentrate on processing the request. With two objects, we can make sure that each behavior (date checking and request processing) is unit-tested cleanly.

隐式依赖仍然是依赖

Implicit Dependencies Are Still Dependencies

我们可以通过使用全局值绕过封装来隐藏组件调用者的依赖关系,但这并不能消除依赖关系,只是使其无法访问。例如,史蒂夫曾经不得不使用一个 Microsoft .Net 库,如果不安装 ActiveDirectory,该库就无法加载——而 ActiveDirectory 实际上并不是他想要使用的功能所必需的,而且他无论如何也无法在他的计算机上安装它。库开发人员试图提供帮助并使其“正常工作”,但结果是史蒂夫根本无法让它工作。

We can hide a dependency from the caller of a component by using a global value to bypass encapsulation, but that doesn’t make the dependency go away—it just makes it inaccessible. For example, Steve once had to work with a Microsoft .Net library that couldn’t be loaded without installing ActiveDirectory—which wasn’t actually required for the features he wanted to use and which he couldn’t install on his machine anyway. The library developer was trying to be helpful and to make it “just work,” but the result was that Steve couldn’t get it to work at all.

面向对象作为一种结构化代码的技术,其目标之一是使对象的边界清晰可见。对象应仅处理本地值和实例(在其范围内创建和管理)或显式传入的值和实例,正如我们在“上下文独立性”(第54页)中强调的那样。

One goal of object orientation as a technique for structuring code is to make the boundaries of an object clearly visible. An object should only deal with values and instances that are either local—created and managed within its scope—or passed in explicitly, as we emphasized in “Context Independence” (page 54).

在上面的例子中,使日期检查可测试的行为迫使我们使Receiver的要求更加明确,并更清楚地思考领域。

In the example above, the act of making date checking testable forced us to make the Receiver’s requirements more explicit and to think more clearly about the domain.

使用与生产代码相同的技术来打破单元测试中的依赖关系

Use the Same Techniques to Break Dependencies in Unit Tests as in Production Code

图像

有几种可用的框架使用诸如操纵类加载器或字节码之类的技术,允许单元测试在不更改目标代码的情况下打破依赖关系。通常,这些是大多数开发人员在编写生产代码时不会使用的高级技术。有时这些工具确实是必要的,但开发人员应该意识到它们会带来隐性成本。

There are several frameworks available that use techniques such as manipulating class loaders or bytecodes to allow unit tests to break dependencies without changing the target code. As a rule, these are advanced techniques that most developers would not use when writing production code. Sometimes these tools really are necessary, but developers should be aware that they come with a hidden cost.

单元测试工具让程序员避开了设计中糟糕的依赖管理,这浪费了宝贵的反馈来源。当开发人员最终需要解决这些设计缺陷以添加一些紧急功能时,他们会发现这很难做到。糟糕的结构会影响依赖于它的系统的其他部分,对原意的任何理解都会烟消云散。就像脏锅碗瓢盆一样,在油污被烤熟之前,更容易把油污去除。

Unit-testing tools that let the programmer sidestep poor dependency management in the design waste a valuable source of feedback. When the developers eventually do need to address these design weaknesses to add some urgent feature, they will find it harder to do. The poor structure will have influenced other parts of the system that rely on it, and any understanding of the original intent will have evaporated. As with dirty pots and pans, it’s easier to get the grease off before it’s been baked in.

日志记录是一项功能

Logging Is a Feature

我们有一个更具争议的、使用难以替换的对象的例子:logging。看一下这两行代码:

We have a more contentious example of working with objects that are hard to replace: logging. Take a look at these two lines of code:

log.error("在 " + timeout + "秒后与现实失去联系");

log.trace("在荒野中行进的距离: " + distance);

log.error("Lost touch with Reality after " + timeout + "seconds");

log.trace("Distance traveled in the wilderness: " + distance);

这是两个独立的功能,但恰好共享一个实现。让我们解释一下。

These are two separate features that happen to share an implementation. Let us explain.

支持日志(错误和信息)是应用程序用户界面的一部分。这些消息旨在供支持人员以及系统管理员和操作员跟踪,以诊断故障或监控正在运行的系统进度。

Support logging (errors and info) is part of the user interface of the application. These messages are intended to be tracked by support staff, as well as perhaps system administrators and operators, to diagnose a failure or monitor the progress of the running system.

诊断日志记录(调试和跟踪)是程序员的基础结构。这些消息不应在生产中打开,因为它们旨在帮助程序员了解他们正在开发的系统内部正在发生的事情。

Diagnostic logging (debug and trace) is infrastructure for programmers. These messages should not be turned on in production because they’re intended to help the programmers understand what’s going on inside the system they’re developing.

鉴于这种区别,我们应该考虑对这两种类型的日志使用不同的技术。支持日志应该根据某些需求进行测试驱动,例如审计或故障恢复。测试将确保我们考虑过每条消息的用途并确保其有效。测试还将保护我们免于破坏其他人编写的用于分析这些日志消息的任何工具和脚本。另一方面,诊断日志是由程序员对系统中发生的事情进行细粒度跟踪的需求驱动的。它是脚手架 - 因此它可能不需要测试驱动,并且消息可能不需要像支持日志那样一致。毕竟,我们不是刚刚同意这些消息不能用于生产吗?

Given this distinction, we should consider using different techniques for these two type of logging. Support logging should be test-driven from somebody’s requirements, such as auditing or failure recovery. The tests will make sure we’ve thought about what each message is for and made sure it works. The tests will also protect us from breaking any tools and scripts that other people write to analyze these log messages. Diagnostic logging, on the other hand, is driven by the programmers’ need for fine-grained tracking of what’s happening in the system. It’s scaffolding—so it probably doesn’t need to be test-driven and the messages might not need to be as consistent as those for support logs. After all, didn’t we just agree that these messages are not to be used in production?

通知而非记录

Notification Rather Than Logging

回到本章的要点,针对静态全局对象(包括记录器)编写单元测试很笨拙。我们必须从文件系统读取或管理额外的附加器对象进行测试;我们必须记住事后清理,以便测试不会互相干扰在正确的记录器上设置正确的级别。测试中的噪音提醒我们,我们的代码在两个层面上工作:我们的领域和日志记录基础设施。以下是带有日志记录的代码的常见示例:

To get back to the point of the chapter, writing unit tests against static global objects, including loggers, is clumsy. We have to either read from the file system or manage an extra appender object for testing; we have to remember to clean up afterwards so that tests don’t interfere with each other and set the right level on the right logger. The noise in the test reminds us that our code is working at two levels: our domain and the logging infrastructure. Here’s a common example of code with logging:

位置 location = tracker.getCurrentLocation();

for (过滤器 filter : filters) {

filter.selectFor(location);

if (logger.isInfoEnabled()) {

logger.info("过滤器 " + filter.getName() + ", " + filter.getDate()

+ " 为 " + location.getName() + " 选择

, 为当前值: " + tracker.isCurrent(location));

}

}

Location location = tracker.getCurrentLocation();

for (Filter filter : filters) {

filter.selectFor(location);

if (logger.isInfoEnabled()) {

logger.info("Filter " + filter.getName() + ", " + filter.getDate()

+ " selected for " + location.getName()

+ ", is current: " + tracker.isCurrent(location));

}

}

请注意循环的功能部分和(强调的)日志记录部分之间的词汇和风格变化。代码同时执行两件事 - 与位置和呈现支持信息有关 - 这违反了单一责任原则。也许我们可以改为这样做:

Notice the shift in vocabulary and style between the functional part of the loop and the (emphasized) logging part. The code is doing two things at once—something to do with locations and rendering support information—which breaks the single responsibility principle. Maybe we could do this instead:

位置 location = tracker.getCurrentLocation();

for (过滤器 filter : filters) {

filter.selectFor(location);

support.notifyFiltering(tracker, location, filter); }

Location location = tracker.getCurrentLocation();

for (Filter filter : filters) {

filter.selectFor(location);

support.notifyFiltering(tracker, location, filter);}

其中support对象可能由记录器、消息总线、弹出窗口或任何适当的东西实现;这个细节与此级别的代码无关。

where the support object might be implemented by a logger, a message bus, pop-up windows, or whatever’s appropriate; this detail is not relevant to the code at this level.

正如您在第 19 章中看到的那样,此代码也更易于测试。我们(而不是日志记录框架)拥有支持对象,因此我们可以在方便时传入模拟实现并将其保留在测试用例本地。另一个简化是,现在我们测试的是对象,而不是字符串的格式化内容。当然,我们仍然需要编写一个实现support和一些有针对性的集成测试来配合它。

This code is also easier to test, as you saw in Chapter 19. We, not the logging framework, own the support object, so we can pass in a mock implementation at our convenience and keep it local to the test case. The other simplification is that now we’re testing for objects, rather than formatted contents of a string. Of course, we will still need to write an implementation of support and some focused integration tests to go with it.

但这只是疯话...

But That’s Crazy Talk...

封装支持报告的想法听起来像是过度设计,但值得考虑一下。这意味着我们是根据意图(帮助支持人员)而不是实现(日志记录)来编写代码,因此更具表现力。所有支持报告都在几个已知的地方处理,因此更容易保持一致的报告方式并鼓励重用。它还可以帮助我们根据应用程序域而不是 Java 包来构建和控制报告。最后,为每个报告编写测试的行为有助于我们避免“我不知道如何处理这个异常,所以我会记录它并继续”综合症,这会导致日志膨胀和生产失败,因为我们没有处理模糊的错误情况。

The idea of encapsulating support reporting sounds like over-design, but it’s worth thinking about for a moment. It means we’re writing code in terms of our intent (helping the support people) rather than implementation (logging), so it’s more expressive. All the support reporting is handled in a few known places, so it’s easier to be consistent about how things are reported and to encourage reuse. It can also help us structure and control our reporting in terms of the application domain, rather than in terms of Java packages. Finally, the act of writing a test for each report helps us avoid the “I don’t know what to do with this exception, so I’ll log it and carry on” syndrome, which leads to log bloat and production failures because we haven’t handled obscure error conditions.

我们听到过一种反对意见:“我无法传递记录器进行测试,因为我的域对象中到处都有记录。我必须到处传递一个记录器。”我们认为这是一种测试味道,告诉我们我们的设计不够清晰。也许我们的一些支持日志实际上应该是诊断日志,或者我们记录了比我们需要的更多的内容,因为我们在尚未理解行为时编写了一些东西。最有可能的是,我们的域代码中仍然有太多重复,我们还没有找到大多数生产日志应该去的“瓶颈”。

One objection we’ve heard is, “I can’t pass in a logger for testing because I’ve got logging all over my domain objects. I’d have to pass one around everywhere.” We think this is a test smell that is telling us that we haven’t clarified our design enough. Perhaps some of our support logging should really be diagnostic logging, or we’re logging more than we need because of something that we wrote when we hadn’t yet understood the behavior. Most likely, there’s still too much duplication in our domain code and we haven’t yet found the “choke points” where most of the production logging should go.

那么诊断日志记录呢?它是工作完成后应拆除的一次性脚手架,还是应进行测试和维护的基本基础设施?这取决于系统,但一旦我们做出区分,我们就可以更自由地考虑使用不同的技术进行支持和诊断日志记录。我们甚至可能认为内联代码不是诊断日志记录的错误技术,因为它会干扰重要的生产代码的可读性。也许我们可以改用某些方面(因为这是使用它们的典型示例);也许不行——但至少我们现在明确了选择。

So what about diagnostic logging? Is it disposable scaffolding that should be taken down once the job is done, or essential infrastructure that should be tested and maintained? That depends on the system, but once we’ve made the distinction we have more freedom to think about using different techniques for support and diagnostic logging. We might even decide that in-line code is the wrong technique for diagnostic logging because it interferes with the readability of the production code that matters. Perhaps we could weave in some aspects instead (since that’s the canonical example of their use); perhaps not—but at least we’ve now clarified the choice.

最后一点数据。我们中的一个人曾经在一个系统上工作,这个系统将大量内容写入日志,以至于一周后必须删除它们才能将其放入磁盘中。这使得维护变得非常困难,因为在分配要修复的错误时,相关日志通常已经消失了。如果他们什么都不记录,系统就会运行得更快,而且不会丢失有用的信息。

One final data point. One of us once worked on a system where so much content was written to the logs that they had to be deleted after a week to fit on the disks. This made maintenance very difficult as the relevant logs were usually gone by the time a bug was assigned to be fixed. If they’d logged nothing at all, the system would have run faster with no loss of useful information.

模拟具体类

Mocking Concrete Classes

交互测试的一种方法是模拟具体类而不是接口。该技术是从要模拟的类继承并重写将在测试中调用的方法,手动或使用以下任何方法模拟框架。我们认为,只有当您真的没有其他选择时才应该使用这种技术。

One approach to interaction testing is to mock concrete classes rather than interfaces. The technique is to inherit from the class you want to mock and override the methods that will be called within the test, either manually or with any of the mocking frameworks. We think this is a technique that should be used only when you really have no other options.

以下是手动模拟的示例。测试验证音乐中心是否在请求的时间启动 CD 播放器。假设在对象上设置时间表CdPlayer涉及触发我们在测试中不想要的某些行为,因此我们覆盖scheduleToStartAt()并在之后验证我们是否使用正确的参数调用了它。

Here’s an example of mocking by hand. The test verifies that the music centre starts the CD player at the requested time. Assume that setting the schedule on a CdPlayer object involves triggering some behavior we don’t want in the test, so we override scheduleToStartAt() and verify afterwards that we’ve called it with the right argument.

公共类 MusicCentreTest {

@Test public void

startsCdPlayerAtTimeRequested() {

final MutableTime ScheduledTime = new MutableTime();

CdPlayer player = new CdPlayer() {

@Override public void scheduleToStartAt(Time startTime) {

scheduleTime.set(startTime);

}

}



MusicCentre centre = new MusicCentre(player);

centre.startMediaAt(LATER);



assertEquals(LATER, schedulingTime.get());

}

}

public class MusicCentreTest {

@Test public void

startsCdPlayerAtTimeRequested() {

final MutableTime scheduledTime = new MutableTime();

CdPlayer player = new CdPlayer() {

@Override public void scheduleToStartAt(Time startTime) {

scheduledTime.set(startTime);

}

}



MusicCentre centre = new MusicCentre(player);

centre.startMediaAt(LATER);



assertEquals(LATER, scheduledTime.get());

}

}

CdPlayer这种方法的问题在于,它使和之间的关系变得MusicCentre隐含。我们希望现在已经明确了,我们在测试驱动开发中的意图是使用模拟对象来显示对象之间的关系。如果我们创建子类,域代码中就没有任何内容可以使这种关系可见 — 只是对象上的方法。这使得我们更难看出支持这种关系的服务是否可能与其他地方相关,下次使用该类时,我们将不得不再次进行分析。为了说明这一点,下面是 的一个可能实现CdPlayer

The problem with this approach is that it leaves the relationship between the CdPlayer and MusicCentre implicit. We hope we’ve made clear by now that our intention in test-driven development is to use mock objects to bring out relationships between objects. If we subclass, there’s nothing in the domain code to make such a relationship visible—just methods on an object. This makes it harder to see if the service that supports this relationship might be relevant elsewhere, and we’ll have to do the analysis again next time we work with the class. To make the point, here’s a possible implementation of CdPlayer:

公共类 CdPlayer {

公共 void scheduleToStartAt(Time startTime) { [...]

公共 void stop() { [...]

公共 void gotoTrack(int trackNumber) { [...]

公共 void spinUpDisk() { [...]

公共 void jet() { [...]

}

public class CdPlayer {

public void scheduleToStartAt(Time startTime) { [...]

public void stop() { [...]

public void gotoTrack(int trackNumber) { [...]

public void spinUpDisk() { [...]

public void eject() { [...]

}

事实证明,我们MusicCentre仅使用 上的启动和停止方法CdPlayer;其余方法由系统的其他部分使用。我们MusicCentre要求它与 对话,这会导致过度指定CdPlayer;它实际上需要的是。Robert Martin 在他的接口隔离原则ScheduledDevice中提出了这一观点(早在 1996 年),即“不应强迫客户端依赖他们不使用的接口”,但这正是我们在模拟具体类时所做的。

It turns out that our MusicCentre only uses the starting and stopping methods on the CdPlayer; the rest are used by some other part of the system. We would be overspecifying the MusicCentre by requiring it to talk to a CdPlayer; what it actually needs is a ScheduledDevice. Robert Martin made the point (back in 1996) in his Interface Segregation Principle that “Clients should not be forced to depend upon interfaces that they do not use,” but that’s exactly what we do when we mock a concrete class.

不模拟具体类还有一个更微妙但更有力的原因。当我们在测试驱动开发过程中提取接口时,我们必须想出一个名称来描述我们刚刚发现的关系 - 在此示例中为ScheduledDevice。我们发现这让我们更加深入地思考领域,并梳理出我们可能忽略的概念。一旦某样东西有了名字,我们就可以谈论它了。

There’s a more subtle but powerful reason for not mocking concrete classes. When we extract an interface as part of our test-driven development process, we have to think up a name to describe the relationship we’ve just discovered—in this example, the ScheduledDevice. We find that this makes us think harder about the domain and teases out concepts that we might otherwise miss. Once something has a name, we can talk about it.

“紧急情况下打破玻璃”

“Break Glass in Case of Emergency”

在某些情况下,我们不得不忍受这种味道。最不可接受的情况是我们正在处理我们控制但无法一次性更改的遗留代码。或者,我们可能正在使用我们根本无法更改的第三方代码(参见第 8 章Logger)。我们发现,在外部库上编写一个饰面几乎总是比直接模拟它更好——但有时,这并不值得。我们在第 19 章中打破了规则,但我们道了很多歉并为此感到内疚。无论如何,这些都是不幸但必要的妥协,我们会尽可能尝试摆脱它们。我们将它们留在代码中的时间越长,设计中的某些脆弱性给我们带来痛苦的可能性就越大。

There are a few occasions when we have to put up with this smell. The least unacceptable situation is where we’re working with legacy code that we control but can’t change all at once. Alternatively, we might be working with third-party code that we can’t change at all (see Chapter 8). We find that it’s almost always better to write a veneer over an external library rather than mock it directly—but occasionally, it’s just not worth it. We broke the rule with Logger in Chapter 19 but apologized a lot and felt bad about it. In any case, these are unfortunate but necessary compromises that we would try to work our way out of when possible. The longer we leave them in the code, the more likely it is that some brittleness in the design will cause us grief.

最重要的是,不要覆盖类的内部功能——这只会将测试锁定在当前实现的怪癖上。仅覆盖可见方法。此规则还禁止在测试中仅为了覆盖而暴露内部方法。如果您无法获得所需的结构,那么测试会告诉您是时候将类分解为更小的可组合功能了。

Above all, do not override a class’ internal features—this just locks down your test to the quirks of the current implementation. Only override visible methods. This rule also prohibits exposing internal methods just to override them in a test. If you can’t get to the structure you need, then the tests are telling you that it’s time to break up the class into smaller, composable features.

不要模拟值

Don’t Mock Values

没有必要为值编写模拟(无论如何它们应该是不可变的)。只需创建一个实例并使用它。例如,在这个测试中Video保存了节目的一部分的详细信息:

There’s no point in writing mocks for values (which should be immutable anyway). Just create an instance and use it. For example, in this test Video holds details of a part of a show:

@Test public void sumsTotalRunningTime() {

Show show = new Show();

Video video1 = context.mock(Video.class); // 不要这样做

Video video2 = context.mock(Video.class);



context.checking(new Expectations(){{

one(video1).time(); will(returnValue(40));

one(video2).time(); will(returnValue(23));

}});



show.add(video1);

show.add(video2);

assertEqual(63, show.runningTime())

}

@Test public void sumsTotalRunningTime() {

Show show = new Show();

Video video1 = context.mock(Video.class); // Don't do this

Video video2 = context.mock(Video.class);



context.checking(new Expectations(){{

one(video1).time(); will(returnValue(40));

one(video2).time(); will(returnValue(23));

}});



show.add(video1);

show.add(video2);

assertEqual(63, show.runningTime())

}

这里,不值得创建一个接口/实现对来控制返回哪些时间值;只需创建具有适当时间的实例并使用它们。

Here, it’s not worth creating an interface/implementation pair to control which time values are returned; just create instances with the appropriate times and use them.

有几种启发式方法可以判断类何时可能是一个值,因此不值得模拟。首先,它的值是不可变的 — 尽管这也可能意味着它是一个调整对象,如“对象对等刻板印象”中所述。其次,我们想不出一个有意义的名称来命名一个可以实现该类型的接口的类。如果它是一个接口,除了 或同样模糊的名称之外,我们还会如何称呼它的类?我们将在第 63页的“ Impl 类毫无意义”中讨论类命名。VideoVideoImpl

There are a couple of heuristics for when a class is likely to be a value and so not worth mocking. First, its values are immutable—although that might also mean that it’s an adjustment object, as described in “Object Peer Stereotypes” (page 52). Second, we can’t think of a meaningful name for a class that would implement an interface for the type. If Video were an interface, what would we call its class other than VideoImpl or something equally vague? We discuss class naming in “Impl Classes Are Meaningless” on page 63.

如果您因为设置实例太复杂而想要模拟某个值,请考虑编写一个构建器;请参阅第 22 章

If you’re tempted to mock a value because it’s too complicated to set up an instance, consider writing a builder; see Chapter 22.

臃肿的构造函数

Bloated Constructor

有时在 TDD 过程中,我们最终会得到一个具有长而笨重的参数列表的构造函数。我们很可能通过一次添加一个对象的依赖项来实现这一点,但结果却失控了。这并不可怕,因为这个过程帮助我们理清了类及其邻居的设计,但现在是时候清理了。我们仍然需要依赖于所有当前构造函数参数的功能,所以我们应该看看那里是否有任何可以梳理出来的隐式结构。

Sometimes during the TDD process, we end up with a constructor that has a long, unwieldy list of arguments. We most likely got there by adding the object’s dependencies one at a time, and it got out of hand. This is not dreadful, since the process helped us sort out the design of the class and its neighbors, but now it’s time to clean up. We will still need the functionality that depends on all the current constructor arguments, so we should see if there’s any implicit structure there that we can tease out.

一种可能性是,一些参数共同定义了一个概念,应该将其打包并替换为一个新对象来表示它。这里有一个小例子:

One possibility is that some of the arguments together define a concept that should be packaged up and replaced with a new object to represent it. Here’s a small example:

public class MessageProcessor {

public MessageProcessor(MessageUnpacker unpacker,

AuditTrail audit,

CounterPartyFinder counteryFinder,

LocationFinder locationFinder,

DomesticNotifier domesticNotifier,

ImportedNotifier importNotifier)

{

// 在此处设置字段

}



public void onMessage(Message rawMessage) {

UnpackedMessage unpacked = unpacker.unpack(rawMessage, counteryFinder);

auditer.recordReceiptOf(unpacked);

// 此处进行其他活动

if (locationFinder.isDomestic(unpacked)) {

domesticNotifier.notify(unpacked.asDomesticMessage());

} else {

importNotifier.notify(unpacked.asImportedMessage())

}

}

}

public class MessageProcessor {

public MessageProcessor(MessageUnpacker unpacker,

AuditTrail auditor,

CounterPartyFinder counterpartyFinder,

LocationFinder locationFinder,

DomesticNotifier domesticNotifier,

ImportedNotifier importedNotifier)

{

// set the fields here

}



public void onMessage(Message rawMessage) {

UnpackedMessage unpacked = unpacker.unpack(rawMessage, counterpartyFinder);

auditor.recordReceiptOf(unpacked);

// some other activity here

if (locationFinder.isDomestic(unpacked)) {

domesticNotifier.notify(unpacked.asDomesticMessage());

} else {

importedNotifier.notify(unpacked.asImportedMessage())

}

}

}

光是想到要为所有这些对象编写期望就让我们感到畏缩,这表明事情太复杂了。第一步是注意到unpackercounterpartyFinder总是一起使用——它们在构造时固定,一个调用另一个。我们可以通过将推counterpartyFinder入来删除一个参数unpacker

Just the thought of writing expectations for all these objects makes us wilt, which suggests that things are too complicated. A first step is to notice that the unpacker and counterpartyFinder are always used together—they’re fixed at construction and one calls the other. We can remove one argument by pushing the counterpartyFinder into the unpacker.

公共类 MessageProcessor {

公共 MessageProcessor(MessageUnpacker unpacker,

AuditTrail audit,

LocationFinder locationFinder,

DomesticNotifier domesticNotifier,

ImportedNotifier importedNotifier) { [...]



公共 void onMessage(Message rawMessage) {

UnpackedMessage unpacked = unpacker.unpack(rawMessage);

// 等等

}

public class MessageProcessor {

public MessageProcessor(MessageUnpacker unpacker,

AuditTrail auditor,

LocationFinder locationFinder,

DomesticNotifier domesticNotifier,

ImportedNotifier importedNotifier) { [...]



public void onMessage(Message rawMessage) {

UnpackedMessage unpacked = unpacker.unpack(rawMessage);

// etc.

}

然后是三元组locationFinder和两个通知程序,它们似乎是一起的。将它们打包成一个可能更有意义MessageDispatcher

Then there’s the triple of locationFinder and the two notifiers, which seem to go together. It might make sense to package them into a MessageDispatcher.

public class MessageProcessor {

public MessageProcessor(MessageUnpacker unpacker,

AuditTrail audit,

MessageDispatcher dispatcher ) { [...]



public void onMessage(Message rawMessage) {

UnpackedMessage unpacked = unpacker.unpack(rawMessage);

auditer.recordReceiptOf(unpacked);

// 这里还有一些其他活动

dispatcher.dispatch(unpacked);

}

}

public class MessageProcessor {

public MessageProcessor(MessageUnpacker unpacker,

AuditTrail auditor,

MessageDispatcher dispatcher) { [...]



public void onMessage(Message rawMessage) {

UnpackedMessage unpacked = unpacker.unpack(rawMessage);

auditor.recordReceiptOf(unpacked);

// some other activity here

dispatcher.dispatch(unpacked);

}

}

尽管我们已将此示例强制放在一个部分中,但它表明,在测试中对复杂性的敏感度可以帮助我们澄清我们的设计。现在我们有一个消息处理对象,它清楚地执行通常的三个阶段:接收、处理和转发。我们已提取消息路由代码(MessageDispatcher),因此MessageProcessor职责更少,并且我们知道在事情变得更加复杂时将路由决策放在哪里。您可能还会注意到,此代码更易于进行单元测试。

Although we’ve forced this example to fit within a section, it shows that being sensitive to complexity in the tests can help us clarify our designs. Now we have a message handling object that clearly performs the usual three stages: receive, process, and forward. We’ve pulled out the message routing code (the MessageDispatcher), so the MessageProcessor has fewer responsibilities and we know where to put routing decisions when things get more complicated. You might also notice that this code is easier to unit-test.

在提取隐式组件时,我们首先要寻找两个条件:在类中始终一起使用的参数以及具有相同生命周期的参数。一旦我们发现巧合,我们就会面临更艰巨的任务,即找到一个能够解释该概念的好名字。

When extracting implicit components, we start by looking for two conditions: arguments that are always used together in the class, and those that have the same lifetime. Once we’ve found a coincidence, we have the harder task of finding a good name that explains the concept.

另外,设计进展顺利的一个标志是这种变化很容易融入。我们所要做的就是找到MessageProcessor创建的位置并进行以下更改:

As an aside, one sign that a design is developing nicely is that this kind of change is easy to integrate. All we have to do is find where the MessageProcessor is created and change this:

消息处理器 =

新的消息处理器(新的 XmlMessageUnpacker()、

审计员、对手方查找器、

位置查找器、国内通知器、

进口通知器);

messageProcessor =

new MessageProcessor(new XmlMessageUnpacker(),

auditor, counterpartyFinder,

locationFinder, domesticNotifier,

importedNotifier);

更改为:

to this:

messageProcessor =

新的MessageProcessor(新的XmlMessageUnpacker(counterpartyFinder),

审计员,

新的MessageDispatcher(

locationFinder,

domesticNotifier,importedNotifier));

messageProcessor =

new MessageProcessor(new XmlMessageUnpacker(counterpartyFinder),

auditor,

new MessageDispatcher(

locationFinder,

domesticNotifier, importedNotifier));

稍后我们可以通过提取出 的创建来减少语法噪声MessageDispatcher

Later we can reduce the syntax noise by extracting out the creation of the MessageDispatcher.

困惑的对象

Confused Object

“臃肿构造函数”的另一种诊断可能是对象本身太大,因为它承担了太多职责。例如,

Another diagnosis for a “bloated constructor” might be that the object itself is too large because it has too many responsibilities. For example,

public class Handset {

public Handset(Network network, Camera camera, Display display,

DataNetwork dataNetwork, AddressBook addressBook,

Storage storage, Tuner tuner, ...)

{

// 在此处设置字段

}

public void placeCallTo(DirectoryNumber number) {

network.openVoiceCallTo(number);

}

public void takePicture() {

Frame frame = storage.allocateNewFrame();

camera.takePictureInto(frame);

display.showPicture(frame);

}

public void showWebPage(URL url) {

display.renderHtml(dataNetwork.retrievePage(url));

}

public void showAddress(SearchTerm searchTerm) {

display.showAddress(addressBook.findAddress(searchTerm));

}

public void playRadio(Frequency frequency) {

tuner.tuneTo(frequency);

tuner.play();

}

// 等等

}

public class Handset {

public Handset(Network network, Camera camera, Display display,

DataNetwork dataNetwork, AddressBook addressBook,

Storage storage, Tuner tuner, ...)

{

// set the fields here

}

public void placeCallTo(DirectoryNumber number) {

network.openVoiceCallTo(number);

}

public void takePicture() {

Frame frame = storage.allocateNewFrame();

camera.takePictureInto(frame);

display.showPicture(frame);

}

public void showWebPage(URL url) {

display.renderHtml(dataNetwork.retrievePage(url));

}

public void showAddress(SearchTerm searchTerm) {

display.showAddress(addressBook.findAddress(searchTerm));

}

public void playRadio(Frequency frequency) {

tuner.tuneTo(frequency);

tuner.play();

}

// and so on

}

就像我们的手机一样,这个类有几个不相关的职责,这迫使它引入许多依赖项。而且,就像我们的手机一样,这个类使用起来很混乱,因为不相关的功能会互相干扰。我们准备好忍受手机中的这些妥协,因为我们没有足够的空间容纳它所包含的所有设备,但这并不适用于代码。这个类应该被分解;Michael Feathers 在[Feathers04]第 20 章中描述了一些这样做的技术。

Like our mobile phones, this class has several unrelated responsibilities which force it to pull in many dependencies. And, like our phones, the class is confusing to use because unrelated features interfere with each other. We’re prepared to put up with these compromises in a handset because we don’t have enough pockets for all the devices it includes, but that doesn’t apply to code. This class should be broken up; Michael Feathers describes some techniques for doing so in Chapter 20 of [Feathers04].

这种类的一个相关问题是它的测试套件看起来也会很混乱。它的各种功能的测试彼此之间没有任何关系,因此我们可以在一个区域进行重大更改而不影响其他区域。如果我们可以将测试类分解成不共享任何内容的片段,那么最好也对对象进行分解。

An associated smell for this kind of class is that its test suite will look confused too. The tests for its various features will have no relationship with each other, so we’ll be able to make major changes in one area without touching others. If we can break up the test class into slices that don’t share anything, it might be best to go ahead and slice up the object too.

依赖过多

Too Many Dependencies

对臃肿构造函数的第三个诊断可能是并非所有参数都是依赖项,这是我们在“对象对等刻板印象” (第52页)中定义的对等刻板印象之一。如该部分所述,我们坚持将依赖项传递给构造函数,但可以将通知和调整设置为默认值并在以后重新配置。当构造函数太大,并且我们不认为参数中存在隐式新类型时,我们可以使用更多默认值,并仅针对特定测试用例覆盖它们。

A third diagnosis for a bloated constructor might be that not all of the arguments are dependencies, one of the peer stereotypes we defined in “Object Peer Stereotypes” (page 52). As discussed in that section, we insist on dependencies being passed in to the constructor, but notifications and adjustments can be set to defaults and reconfigured later. When a constructor is too large, and we don’t believe there’s an implicit new type amongst the arguments, we can use more default values and only overwrite them for particular test cases.

这是一个例子——它还没有糟糕到需要修复的程度,但足以说明问题。该应用程序是一款赛车游戏;玩家可以尝试不同的汽车配置和驾驶风格,看看哪一种会获胜。1 ARacingCar代表比赛中的参赛者:

Here’s an example—it’s not quite bad enough to need fixing, but it’ll do to make the point. The application is a racing game; players can try out different configurations of car and driving style to see which one wins.1 A RacingCar represents a competitor within a race:

1.纳特曾经从事过跟踪一级方程式赛车赛道的工作。

1. Nat once worked in a job that involved following the Formula One circuit.

公共类 RacingCar {

私人最终赛道 赛道;

私人轮胎 轮胎;

私人悬架 悬架;

私人机翼 前翼;

私人机翼 后翼;

私人双燃料负载;

私人 CarListener 监听器;

私人驾驶策略 驾驶员;

公共 RacingCar(赛道 赛道, 驾驶策略 驾驶员, 轮胎 轮胎,

悬架 悬架, 机翼 前翼, 机翼 后翼,

双燃料负载, CarListener 监听器)

{

this.track = 赛道;

this.driver = 驾驶员;

this.tyres =

轮胎;

this.suspension = 悬架; this.frontWing = frontWing;

this.backWing = backWing;

this.fuelLoad = fuelLoad;

this.listener = 监听器;

}

}

public class RacingCar {

private final Track track;

private Tyres tyres;

private Suspension suspension;

private Wing frontWing;

private Wing backWing;

private double fuelLoad;

private CarListener listener;

private DrivingStrategy driver;

public RacingCar(Track track, DrivingStrategy driver, Tyres tyres,

Suspension suspension, Wing frontWing, Wing backWing,

double fuelLoad, CarListener listener)

{

this.track = track;

this.driver = driver;

this.tyres = tyres;

this.suspension = suspension;

this.frontWing = frontWing;

this.backWing = backWing;

this.fuelLoad = fuelLoad;

this.listener = listener;

}

}

事实证明,这track是 的唯一依赖项RacingCar;提示是,它是唯一最终的字段。listener是一个通知,其他一切都是调整;所有这些都可以由用户在比赛前或比赛期间修改。这是一个重新设计的构造函数:

It turns out that track is the only dependency of a RacingCar; the hint is that it’s the only field that’s final. The listener is a notification, and everything else is an adjustment; all of these can be modified by the user before or during the race. Here’s a reworked constructor:

公共类 RacingCar {

私有最终 Track 轨道;



私有 DrivingStrategy 驾驶员 = DriverTypes.borderlineAggressiveDriving();

私有轮胎 轮胎 = TyreTypes.mediumSlicks();

私有悬架 悬架 = SuspensionTypes.mediumStiffness();

私有机翼 frontWing = WingTypes.mediumDownforce();

私有机翼 backWing = WingTypes.mediumDownforce();

私有双燃料负载 = 0.5;



私有 CarListener 监听器 = CarListener.NONE;



公共 RacingCar(Track 轨道) {

this.track = track;

}



公共 void setSuspension(悬架 悬架) { [...]

公共 void setTyres(Tyres 轮胎) { [...]

公共 void setEngine(引擎 引擎) { [...]



公共 void setListener(CarListener 监听器) { [...]

}

public class RacingCar {

private final Track track;



private DrivingStrategy driver = DriverTypes.borderlineAggressiveDriving();

private Tyres tyres = TyreTypes.mediumSlicks();

private Suspension suspension = SuspensionTypes.mediumStiffness();

private Wing frontWing = WingTypes.mediumDownforce();

private Wing backWing = WingTypes.mediumDownforce();

private double fuelLoad = 0.5;



private CarListener listener = CarListener.NONE;



public RacingCar(Track track) {

this.track = track;

}



public void setSuspension(Suspension suspension) { [...]

public void setTyres(Tyres tyres) { [...]

public void setEngine(Engine engine) { [...]



public void setListener(CarListener listener) { [...]

}

现在我们已将这些对等点初始化为通用默认值;用户可以稍后通过用户界面配置它们,我们也可以在我们的单元测试中配置它们。我们已将初始化listener为空对象,同样,这稍后可以通过对象的环境进行更改。

Now we’ve initialized these peers to common defaults; the user can configure them later through the user interface, and we can configure them in our unit tests. We’ve initialized the listener to a null object, again this can be changed later by the object’s environment.

期望太多

Too Many Expectations

当测试包含太多期望时,很难看清哪些是重要的,哪些是真正需要测试的。例如,这是一个测试:

When a test has too many expectations, it’s hard to see what’s important and what’s really under test. For example, here’s a test:

@Test public void

determineCasesWhenFirstPartyIsReady() {

context.checking(new Expectations(){{

one(firstPart).isReady(); will(returnValue(true));

one(organizer).getAdjudicator(); will(returnValue(adjudicator));

one(adjudicator).findCase(firstParty,issue); will(returnValue(case));

one(thirdParty).proceedWith(case);

}});



claimsProcessor.adjudicateIfReady(thirdParty,issue);

}

@Test public void

decidesCasesWhenFirstPartyIsReady() {

context.checking(new Expectations(){{

one(firstPart).isReady(); will(returnValue(true));

one(organizer).getAdjudicator(); will(returnValue(adjudicator));

one(adjudicator).findCase(firstParty, issue); will(returnValue(case));

one(thirdParty).proceedWith(case);

}});



claimsProcessor.adjudicateIfReady(thirdParty, issue);

}

可以这样实现:

that might be implemented like this:

公共无效 adjudicateIfReady(ThirdParty thirdParty,Issue issue){

如果(firstParty.isReady()){

裁决者adjudicator = organization.getAdjudicator();

案件case = adjudicator.findCase(firstParty,issue);

thirdParty.proceedWith(case);

} else{

thirdParty.adjourn();

}

}

public void adjudicateIfReady(ThirdParty thirdParty, Issue issue) {

if (firstParty.isReady()) {

Adjudicator adjudicator = organization.getAdjudicator();

Case case = adjudicator.findCase(firstParty, issue);

thirdParty.proceedWith(case);

} else{

thirdParty.adjourn();

}

}

考试难读的原因在于,所有内容都是预期结果,因此所有内容看起来都同等重要。我们无法分辨哪些内容重要,哪些内容只是为了通过考试。

What makes the test hard to read is that everything is an expectation, so everything looks equally important. We can’t tell what’s significant and what’s just there to get through the test.

事实上,如果我们查看所有调用的方法,只有两个方法在此类之外会产生副作用:thirdParty.proceedWith()thirdParty.adjourn();多次调用这些方法会出错。所有其他方法都是查询;我们可以organization.getAdjudicator()重复调用而不会破坏任何行为。adjudicator.findCase()可能会出现任何一种情况,但它恰好是查找,因此没有副作用。

In fact, if we look at all the methods we call, there are only two that have any side effects outside this class: thirdParty.proceedWith() and thirdParty.adjourn(); it would be an error to call these more than once. All the other methods are queries; we can call organization.getAdjudicator() repeatedly without breaking any behavior. adjudicator.findCase() might go either way, but it happens to be a lookup so it has no side effects.

我们可以通过区分存根(帮助我们通过测试的真实行为的模拟)和期望(我们想要对对象如何与其邻居交互做出的断言)来使我们的意图更加清晰。在“允许和期望”(第 277页)中对这种区别进行了更详细的讨论。重新编写测试,我们得到:

We can make our intentions clearer by distinguishing between stubs, simulations of real behavior that help us get the test to pass, and expectations, assertions we want to make about how an object interacts with its neighbors. There’s a longer discussion of this distinction in “Allowances and Expectations” (page 277). Reworking the test, we get:

@Test public void determineCasesWhenFirstPartyIsReady() {

context.checking(new Expectations(){{

allowing (firstPart).isReady(); will(returnValue(true));

allowing (organizer).getAdjudicator(); will(returnValue(adjudicator));

allowing (adjudicator).findCase(firstParty, issue); will(returnValue(case));



one(thirdParty).proceedWith(case);

}});



claimsProcessor.adjudicateIfReady(thirdParty, issue);

}

@Test public void decidesCasesWhenFirstPartyIsReady() {

context.checking(new Expectations(){{

allowing(firstPart).isReady(); will(returnValue(true));

allowing(organizer).getAdjudicator(); will(returnValue(adjudicator));

allowing(adjudicator).findCase(firstParty, issue); will(returnValue(case));



one(thirdParty).proceedWith(case);

}});



claimsProcessor.adjudicateIfReady(thirdParty, issue);

}

它更明确地说明了我们期望物体如何改变周围的世界。

which is more explicit about how we expect the object to change the world around it.

写下一些期望

Write Few Expectations

图像

同事 Romilly Cocking 刚开始与我们合作时,对我们在单元测试中通常只写很少的期望感到惊讶。就像“每个人”现在都学会了避免在测试中写太多断言一样,我们也尽量避免过多的期望。如果我们有太多的期望,那么要么是我们试图测试的单元太大,要么是我们锁定了太多的对象交互。

A colleague, Romilly Cocking, when he first started working with us, was surprised by how few expectations we usually write in a unit test. Just like “everyone” has now learned to avoid too many assertions in a test, we try to avoid too many expectations. If we have more than a few, then either we’re trying to test too large a unit, or we’re locking down too many of the object’s interactions.

特别奖金

Special Bonus Prize

我们总是想不出好的例子。实际上,这段代码有一个更好的改进,那就是注意到我们已经拉出了一个对象链来获取案例对象,从而暴露了与此无关的依赖关系。相反,我们应该告诉最近的对象为我们完成工作,如下所示:

We always have problems coming up with good examples. There’s actually a better improvement to this code, which is to notice that we’ve pulled out a chain of objects to get to the case object, exposing dependencies that aren’t relevant here. Instead, we should have told the nearest object to do the work for us, like this:

公共无效 adjudicateIfReady(ThirdParty thirdParty,Issue issue){

如果(firstParty.isReady()){

组织.adjudicateBetween(firstParty,thirdParty,issue);

} else {

thirdParty.adjourn();

}

}

public void adjudicateIfReady(ThirdParty thirdParty, Issue issue) {

if (firstParty.isReady()) {

organization.adjudicateBetween(firstParty, thirdParty, issue);

} else {

thirdParty.adjourn();

}

}

或者,可能

or, possibly,

公共无效 adjudicateIfReady(ThirdParty thirdParty,Issue issue){如果

(firstParty.isReady()){

thirdParty。startAdjudication (组织,firstParty,issue); } else{ thirdParty.adjourn(); } }







public void adjudicateIfReady(ThirdParty thirdParty, Issue issue) {

if (firstParty.isReady()) {

thirdParty.startAdjudication(organization, firstParty, issue);

} else{

thirdParty.adjourn();

}

}

看起来更加平衡。如果您发现了这一点,我们将奖励您一个 Moment of Smugness™,供您随时使用。

which looks more balanced. If you spotted this, we award you a Moment of Smugness™ to be exercised at your convenience.

测试会告诉我们什么(如果我们仔细聆听)

What the Tests Will Tell Us (If We’re Listening)

我们发现学会倾听测试气味有以下好处:

We’ve found these benefits from learning to listen to test smells:

保持知识本地化

Keep knowledge local

我们发现的一些测试异味,例如需要“魔法”来创建模拟,与组件之间的知识泄漏有关。如果我们可以将知识保留在对象本地(内部或传入),那么它的实现就独立于其上下文;我们可以安全地将其移动到我们喜欢的任何位置。始终如一地这样做,您的应用程序(由可插入组件构建)将很容易更改。

Some of the test smells we’ve identified, such as needing “magic” to create mocks, are to do with knowledge leaking between components. If we can keep knowledge local to an object (either internal or passed in), then its implementation is independent of its context; we can safely move it wherever we like. Do this consistently and your application, built out of pluggable components, will be easy to change.

如果它是明确的,我们可以命名它

If it’s explicit, we can name it

我们不喜欢模拟具体类的原因之一是,我们喜欢为对象之间的关系以及对象本身命名。正如传说所说,如果我们知道某物的真实名称,我们就可以控制它。如果我们能看到它,我们就有更好的机会找到它的其他用途,从而减少重复。

One reason why we don’t like mocking concrete classes is that we like to have names for the relationships between objects as well the objects themselves. As the legends say, if we have something’s true name, we can control it. If we can see it, we have a better chance of finding its other uses and so reducing duplication.

更多名称意味着更多域名信息

More names mean more domain information

我们发现,当我们强调对象如何通信,而不是对象是什么时,我们最终会更多地根据领域而不是实现来定义类型和角色。这可能是因为我们拥有更多较小的抽象,这让我们离底层语言越来越远。不知何故,我们似乎在代码中加入了更多领域词汇。

We find that when we emphasize how objects communicate, rather than what they are, we end up with types and roles defined more in terms of the domain than of the implementation. This might be because we have a greater number of smaller abstractions, which gets us further away from the underlying language. Somehow we seem to get more domain vocabulary into the code.

传递行为而不是数据

Pass behavior rather than data

我们发现,通过始终如一地应用“告知而不是询问”,我们最终会形成一种编码风格,即我们倾向于将行为(以回调的形式)传递到系统中,而不是通过堆栈向上拉取值。例如,在第 17 章中,我们引入了一个SniperCollector在被告知有新的时做出响应的Sniper。将此侦听器传递到 Sniper 创建代码中可以比我们公开要添加的集合更好地隐藏信息。更精确的接口可以让我们更好地隐藏信息并实现更清晰的抽象。

We find that by applying “Tell, Don’t Ask” consistently, we end up with a coding style where we tend to pass behavior (in the form of callbacks) into the system instead of pulling values up through the stack. For example, in Chapter 17, we introduced a SniperCollector that responds when told about a new Sniper. Passing this listener into the Sniper creation code gives us better information hiding than if we’d exposed a collection to be added to. More precise interfaces give us better information-hiding and clearer abstractions.

我们关心的是保持测试和代码的整洁,因为这有助于确保我们了解我们的领域,并降低在新的需求触发设计变更时无法应对的风险。保持代码库的整洁比从混乱中恢复要容易得多。一旦代码库开始“腐烂”,开发人员就会面临压力,不得不修改代码以完成下一项工作。不需要太多这样的事件就能消磨团队的良好意愿。

We care about keeping the tests and code clean as we go, because it helps to ensure that we understand our domain and reduces the risk of being unable to cope when a new requirement triggers changes to the design. It’s much easier to keep a codebase clean than to recover from a mess. Once a codebase starts to “rot,” the developers will be under pressure to botch the code to get the next job done. It doesn’t take many such episodes to dissipate a team’s good intentions.

我们曾经在 jMock 用户列表中发布过一篇帖子,其中包含以下评论:

We once had a posting to the jMock user list that included this comment:

我最近参与了一个项目,其中大量使用了 jMock。回顾过去,我发现了以下几点:

I was involved in a project recently where jMock was used quite heavily. Looking back, here’s what I found:

1. 单元测试有时难以理解(不知道它们在做什么)。

1. The unit tests were at times unreadable (no idea what they were doing).

2.有些测试类会达到500行,另外继承一个抽象类也会有500行。

2. Some tests classes would reach 500 lines in addition to inheriting an abstract class which also would have up to 500 lines.

3.重构会导致测试代码的大量改变。

3. Refactoring would lead to massive changes in test code.

单元测试不应该有 1000 行!它应该最多关注几个类,并且不需要创建大型装置或执行大量准备工作,只是为了让对象处于可以执行目标功能的状态。这样的测试很难理解——阅读时要记住的东西太多了。当然,它们很脆弱,所有参与其中的对象都过于紧密地耦合,很难设置为测试所需的状态。

A unit test shouldn’t be 1000 lines long! It should focus on at most a few classes and should not need to create a large fixture or perform lots of preparation just to get the objects into a state where the target feature can be exercised. Such tests are hard to understand—there’s just so much to remember when reading them. And, of course, they’re brittle, all the objects in play are too tightly coupled and too difficult to set to the state the test requires.

测试驱动开发可能很难管。质量差的测试可能会使开发速度慢如蜗牛,而测试系统的内部质量差也会导致测试质量差。通过留意内部质量反馈,我们可以通过编写测试,我们可以在单元测试接近 1000 行代码之前就将这个问题扼杀在萌芽状态,并最终得到我们可以接受的测试。相反,努力编写可读且灵活的测试可以让我们更多地了解正在测试的代码的内部质量。我们最终得到的测试有助于而不是阻碍持续的开发。

Test-driven development can be unforgiving. Poor quality tests can slow development to a crawl, and poor internal quality of the system being tested will result in poor quality tests. By being alert to the internal quality feedback we get from writing tests, we can nip this problem in the bud, long before our unit tests approach 1000 lines of code, and end up with tests we can live with. Conversely, making an effort to write tests that are readable and flexible gives us more feedback about the internal quality of the code we are testing. We end up with tests that help, rather than hinder, continued development.

第 21 章测试可读性

Chapter 21. Test Readability

设计就是通过你可以控制或掌握的任何方式来清晰地传达信息。

To design is to communicate clearly by whatever means you can control or master.

—米尔顿·格拉泽

—Milton Glaser

介绍

Introduction

采用 TDD 的团队通常会在早期看到生产力的提升,因为测试让他们可以自信地添加功能并立即发现错误。对于一些团队来说,随着测试本身成为维护负担,速度会减慢。为了使 TDD 可持续,测试必须做的不仅仅是验证代码的行为;它们还必须清楚地表达该行为——它们必须可读。这很重要,原因与代码可读性一样:每次开发人员不得不停下来,通过测试来弄清楚它的含义时,他们就没有那么多时间花在创建新功能上,团队的速度就会下降。

Teams that adopt TDD usually see an early boost in productivity because the tests let them add features with confidence and catch errors immediately. For some teams, the pace then slows down as the tests themselves become a maintenance burden. For TDD to be sustainable, the tests must do more than verify the behavior of the code; they must also express that behavior clearly—they must be readable. This matters for the same reason that code readability matters: every time the developers have to stop and puzzle through a test to figure out what it means, they have less time left to spend on creating new features, and the team velocity drops.

我们对编写测试代码和生产代码的关注程度一样高,但由于两种类型的代码用途不同,因此在风格上有所不同。测试代码应该描述生产代码的作用。这意味着它倾向于具体化它使用的值作为预期结果的示例,但对代码的工作方式则抽象化。另一方面,生产代码倾向于抽象它操作的值,但对它如何完成工作则具体化。同样,在编写生产代码时,我们必须考虑如何组合对象以组成一个工作系统,并仔细管理它们的依赖关系。另一方面,测试代码位于依赖链的末端,因此对它来说,表达目标代码的意图比插入其他对象的网络更重要。我们希望我们的测试代码读起来就像对正在测试的内容的声明性描述。

We take as much care about writing our test code as about production code, but with differences in style since the two types of code serve different purposes. Test code should describe what the production code does. That means that it tends to be concrete about the values it uses as examples of what results to expect, but abstract about how the code works. Production code, on the other hand, tends to be abstract about the values it operates on but concrete about how it gets the job done. Similarly, when writing production code, we have to consider how we will compose our objects to make up a working system, and manage their dependencies carefully. Test code, on the other hand, is at the end of the dependency chain, so it’s more important for it to express the intention of its target code than to plug into a web of other objects. We want our test code to read like a declarative description of what is being tested.

在本章中,我们将描述一些我们发现有助于保持测试的可读性和表现力的实践。

In this chapter, we’ll describe some practices that we’ve found helpful to keep our tests readable and expressive.

测试名称描述特征

Test Names Describe Features

测试的名称应该是开发人员了解正在测试什么以及目标对象应该如何表现的第一个线索。

The name of the test should be the first clue for a developer to understand what is being tested and how the target object is supposed to behave.

并非每个与我们合作过的团队都遵循这一原则。一些天真的开发人员使用的名称根本没有任何意义:

Not every team we’ve worked with follows this principle. Some naive developers use names that don’t mean anything at all:

公共类 TargetObjectTest {

@Test 公共 void test1() { [...]

@Test 公共 void test2() { [...]

@Test 公共 void test3() { [...]

public class TargetObjectTest {

@Test public void test1() { [...]

@Test public void test2() { [...]

@Test public void test3() { [...]

如今我们很少看到这样的测试;世界已经发生了变化。一种常见的方法是根据测试所执行的方法命名测试:

We don’t see many of these nowadays; the world has moved on. A common approach is to name a test after the method it’s exercising:

公共类 TargetObjectTest {

@Test 公共 void isReady() { [...]

@Test 公共 void choose() { [...]

@Test 公共 void choose1() { [...]



公共类 TargetObject {

公共 void isReady() { [...]

公共 void choose(Picker picker) { [...]

public class TargetObjectTest {

@Test public void isReady() { [...]

@Test public void choose() { [...]

@Test public void choose1() { [...]



public class TargetObject {

public void isReady() { [...]

public void choose(Picker picker) { [...]

也许可以通过同一种方法对不同的路径进行多次测试。

perhaps with multiple tests for different paths through the same method.

充其量,这样的名称重复了开发人员通过查看目标类即可获得的信息;它们违反了“不要重复自己”的原则[Hunt99]。我们不需要知道它TargetObject有一个choose()方法——我们需要知道对象在不同情况下做什么,这个方法是用来做什么的。

At best, such names duplicate the information a developer could get just by looking at the target class; they break the “Don’t Repeat Yourself” principle [Hunt99]. We don’t need to know that TargetObject has a choose() method—we need to know what the object does in different situations, what the method is for.

更好的选择是根据目标对象提供的功能来命名测试。我们使用TestDox约定(由 Chris Stevenson 发明),其中每个测试名称读起来都像一个句子,目标类是隐含的主语。例如,

A better alternative is to name tests in terms of the features that the target object provides. We use a TestDox convention (invented by Chris Stevenson) where each test name reads like a sentence, with the target class as the implicit subject. For example,

• AList按照添加的顺序保存项目。

• A List holds items in the order they were added.

• AList可以保存对同一项目的多个引用。

• A List can hold multiple references to the same item.

• AList在移除不包含的项目时抛出异常。

• A List throws an exception when removing an item it doesn’t hold.

我们可以将它们直接转换为方法名称:

We can translate these directly to method names:

公共类 ListTests {

@Test public void holdItemsInTheOrderTheyWereAdded() { [...]

@Test public void canHoldMultipleReferencesToTheSameItem() { [...]

@Test public void throwsAnExceptionWhenRemovingAnItemItDoesntHold() { [...]

public class ListTests {

@Test public void holdsItemsInTheOrderTheyWereAdded() { [...]

@Test public void canHoldMultipleReferencesToTheSameItem() { [...]

@Test public void throwsAnExceptionWhenRemovingAnItemItDoesntHold() { [...]

这些名称可以任意长,因为它们只能通过反射来调用——我们永远不必输入它们来调用它们。

These names can be as long as we like because they’re only called through reflection—we never have to type them in to call them.

约定的重点是鼓励开发人员思考目标对象的作用而不是它什么。它也与我们每次向现有代码库添加功能的增量方法非常兼容。它为我们提供了从用户故事到任务和验收测试再到单元测试的一致命名风格 - 正如您在第 III 部分中看到的那样。

The point of the convention is to encourage the developer to think in terms of what the target object does, not what it is. It’s also very compatible with our incremental approach of adding a feature at a time to an existing codebase. It gives us a consistent style of naming all the way from user stories, through tasks and acceptance tests, to unit tests—as you saw in Part III.

就风格而言,测试名称应该说明预期结果、对对象的操作以及场景的动机。例如,如果我们要测试一个ConnectionMonitor类,那么

As a matter of style, the test name should say something about the expected result, the action on the object, and the motivation for the scenario. For example, if we were testing a ConnectionMonitor class, then

轮询服务器监控端口()

pollsTheServersMonitoringPort()

并没有告诉我们足够多的信息:为什么要进行轮询,得到结果后会发生什么?另一方面,

doesn’t tell us enough: why does it poll, what happens when it gets a result? On the other hand,

当无法连接到其监控端口时,通知监听器该服务器不可用()

notifiesListenersThatServerIsUnavailableWhenCannotConnectToItsMonitoringPort()

解释了场景和预期行为。稍后我们将展示这种命名风格如何映射到我们的标准测试结构上。

explains both the scenario and the expected behavior. We’ll show later how this style of naming maps onto our standard test structures.

测试名称是名还是姓?

Test Name First or Last?

图像

我们注意到,有些开发人员会先使用占位符名称,然后填写测试主体,再决定如何命名。而其他人(例如 Steve)则喜欢先确定测试名称,以明确他们的意图,然后再编写任何测试代码。只要开发人员坚持到底并确保测试最终一致且富有表现力,这两种方法都可以。

We’ve noticed that some developers start with a placeholder name, fill out the body of the test, and then decide what to call it. Others (such as Steve) like to decide the test name first, to clarify their intentions, before writing any test code. Both approaches work as long as the developer follows through and makes sure that the test is, in the end, consistent and expressive.

TestDox 格式实现了 TDD 的早期承诺——测试应充当代码的文档。有一些工具和 IDE 插件可以解压“驼峰式”方法名称并将其链接到被测类,例如 IntelliJ IDE 的 TestDox 插件;图 21.1显示了类的自动文档KeyboardLayout

The TestDox format fulfills the early promise of TDD—that the tests should act as documentation for the code. There are tools and IDE plug-ins that unpack the “camel case” method names and link them to the class under test, such as the TestDox plug-in for the IntelliJ IDE; Figure 21.1 shows the automatic documentation for a KeyboardLayout class.

图 21.1 TestDox IntelliJ 插件

Figure 21.1 The TestDox IntelliJ plug-in

图像

定期阅读测试生成的文档

Regularly Read Documentation Generated from Tests

图像

我们发现,这样生成的文档让我们对测试名称有了全新的认识,突出了我们过于接近代码而看不到的问题。例如,在生成图 21.1的屏幕截图时,Nat 注意到第一个测试的名称不清楚——应该是“在所有已知布局中将数字转换按键”。

We find that such generated documentation gives us a fresh perspective on the test names, highlighting the problems we’re too close to the code to see. For example, when generating the screenshot for Figure 21.1, Nat noticed that the name of the first test is unclear—it should be “translates numbers to key strokes in all known layouts.”

我们努力在开发过程中至少定期浏览文档。

We make an effort to at least skim-read the documentation regularly during development.

规范测试结构

Canonical Test Structure

我们发现,如果以标准形式编写测试,它们会更容易理解。我们可以快速浏览以找到期望和断言,并了解它们与被测代码的关系。如果我们发现以标准形式编写测试很困难,这通常表明代码太复杂或我们还没有完全理清思路。

We find that if we write our tests in a standard form, they’re easier to understand. We can skim-read to find expectations and assertions quickly and see how they relate to the code under test. If we’re finding it difficult to write a test in a standard form, that’s often a hint that the code is too complicated or that we haven’t quite clarified our ideas.

最常见的测试形式是:

The most common form for a test is:

1. Setup:准备测试上下文,即目标代码将运行的环境;

1. Setup: prepare the context of the test, the environment in which the target code will run;

2.执行:调用目标代码,触发测试行为;

2. Execute: call the target code, triggering the tested behavior;

3.验证:检查该行为是否产生了我们期望的可见效果;

3. Verify: check for a visible effect that we expect from the behavior; and,

4.拆卸:清理任何可能破坏其他测试的剩余状态。

4. Teardown: clean up any leftover state that might corrupt other tests.

此形式还有其他版本,例如“安排、行动、断言”,其中折叠了部分阶段。

There are other versions of this form, such as “Arrange, Act, Assert,” which collapse some of the stages.

例如:

For example:

public class StringTemplateTest {

@Test public void expandsMacrosSurroundedWithBraces() {

StringTemplate template = new StringTemplate("{a}{b}"); // 设置

HashMap<String,Object> macros = new HashMap<String,Object>();

macros.put("a", "A");

macros.put("b", "B");



String expand = template.expand(macros); // 执行

assertThat(expanded, equalTo("AB")); // 断言

} // 无拆卸

}

public class StringTemplateTest {

@Test public void expandsMacrosSurroundedWithBraces() {

StringTemplate template = new StringTemplate("{a}{b}"); // Setup

HashMap<String,Object> macros = new HashMap<String,Object>();

macros.put("a", "A");

macros.put("b", "B");



String expanded = template.expand(macros); // Execute

assertThat(expanded, equalTo("AB")); // Assert

} // No Teardown

}

对模拟对象设置期望的测试使用此结构的变体,其中一些断言在执行阶段之前声明,并在之后进行隐式检查 - 例如,在第 19 章LoggingXMPPFailureReporterTest中:

Tests that set expectations on mock objects use a variant of this structure where some of the assertions are declared before the execute stage and are implicitly checked afterwards—for example, in LoggingXMPPFailureReporterTest from Chapter 19:

@RunWith(JMock.class)

public class LoggingXMPPFailureReporterTest {

private final Mockery context = new Mockery() {{ // 设置

setImposteriser(ClassImposteriser.INSTANCE);

};



final Logger logger = context.mock(Logger.class);

final LoggingXMPPFailureReporter reporter = new LoggingXMPPFailureReporter(logger);

@Test public void writesMessageTranslationFailureToLog() {

Exception exception = new Exception("an exception");

context.checking(new Expectations() {{ // Expect

oneOf(logger).severe( expected log message here );

}});



reporter.cannotTranslateMessage("auction id", // 执行

“失败消息”, exception);

// 隐式检查期望是否得到满足 // 断言

}



@AfterClass public static void resetLogging() { // 拆卸

LogManager.getLogManager().reset();

}

}

@RunWith(JMock.class)

public class LoggingXMPPFailureReporterTest {

private final Mockery context = new Mockery() {{ // Setup

setImposteriser(ClassImposteriser.INSTANCE);

}};



final Logger logger = context.mock(Logger.class);

final LoggingXMPPFailureReporter reporter = new LoggingXMPPFailureReporter(logger);

@Test public void writesMessageTranslationFailureToLog() {

Exception exception = new Exception("an exception");

context.checking(new Expectations() {{ // Expect

oneOf(logger).severe( expected log message here);

}});



reporter.cannotTranslateMessage("auction id", // Execute

"failed message", exception);

// implicitly check expectations are satisfied // Assert

}



@AfterClass public static void resetLogging() { // Teardown

LogManager.getLogManager().reset();

}

}

反向编写测试

Write Tests Backwards

图像

尽管我们坚持使用规范的测试代码格式,但我们不一定会从上到下编写测试。我们经常做的是:编写测试名称,这有助于我们决定要实现的目标;编写对目标代码的调用,这是该功能的入口点;编写期望和断言,以便我们知道该功能应该产生什么效果;编写设置和拆卸以定义测试的上下文。当然,为了帮助编译器,这些步骤可能会有些模糊,但这个顺序反映了我们倾向于如何思考新的单元测试。然后我们运行它并观察它失败。

Although we stick to a canonical format for test code, we don’t necessarily write tests from top to bottom. What we often do is: write the test name, which helps us decide what we want to achieve; write the call to the target code, which is the entry point for the feature; write the expectations and assertions, so we know what effects the feature should have; and, write the setup and teardown to define the context for the test. Of course, there may be some blurring of these steps to help the compiler, but this sequence reflects how we tend to think through a new unit test. Then we run it and watch it fail.

简化测试代码

Streamline the Test Code

所有代码都应强调“做什么”而不是“怎么做”,包括测试代码;测试方法中包含的实现细节越多,读者就越难理解什么是重要的。我们试图将与所执行功能的描述(从领域术语上讲)无关的所有内容移出测试方法。有时这需要重构代码,有时只需忽略语法噪音即可。

All code should emphasize “what” it does over “how,” including test code; the more implementation detail is included in a test method, the harder it is for the reader to understand what’s important. We try to move everything out of the test method that doesn’t contribute to the description, in domain terms, of the feature being exercised. Sometimes that involves restructuring the code, sometimes just ignoring the syntax noise.

使用结构来解释

Use Structure to Explain

正如您在第 III 部分中看到的,我们特别注重遵循“用小方法表达意图”(第226页),甚至编写了一个微小的方法,例如translatorFor()只是为了减少 Java 语法噪音。这非常适合 Hamcrest 方法,其中assertThat()和 jMock 期望语法旨在允许开发人员将小功能组合成(或多或少)可读的断言描述。例如,

As you’ll have seen throughout Part III, we make a point of following “Small Methods to Express Intent” (page 226), even to the extent of writing a tiny method like translatorFor() just to reduce the Java syntax noise. This fits nicely into the Hamcrest approach, where the assertThat() and jMock expectation syntaxes are designed to allow developers to compose small features into a (more or less) readable description of an assertion. For example,

断言(仪器,hasItem(instrumentWithPrice(greaterThan(81))));

assertThat(instruments, hasItem(instrumentWithPrice(greaterThan(81))));

检查集合中是否instruments至少有一个Instrument属性strikePrice大于 81。断言行表达了我们的意图,辅助方法创建一个检查值的匹配器:

checks whether the collection instruments has at least one Instrument with a strikePrice property greater than 81. The assertion line expresses our intent, the helper method creates a matcher that checks the value:

私有 Matcher<? super Instrument>

InstrumentWithPrice(Matcher<? super Integer> priceMatcher) {

返回新的 FeatureMatcher<Instrument, Integer>(

priceMatcher, "instrument at price", "price") {

@Override protected Integer featureValueOf(Instrument actual) {

返回 actual.getStrikePrice();

}

};

}

private Matcher<? super Instrument>

instrumentWithPrice(Matcher<? super Integer> priceMatcher) {

return new FeatureMatcher<Instrument, Integer>(

priceMatcher, "instrument at price", "price") {

@Override protected Integer featureValueOf(Instrument actual) {

return actual.getStrikePrice();

}

};

}

这最终可能会创建更多的程序文本,但我们优先考虑表现力而不是最小化源代码行。

This may create more program text in the end, but we’re prioritizing expressiveness over minimizing the source lines.

使用结构来共享

Use Structure to Share

我们还将通用特性提取到可在测试之间共享的方法中,用于设置值、拆除状态、做出断言以及偶尔触发事件。例如,在第 19 章中,我们利用 jMock 设置多个期望块的功能来编写一个expectSniperToFailWhenItIs()方法,该方法将重复的行为包装在一个描述性名称后面。

We also extract common features into methods that can be shared between tests for setting up values, tearing down state, making assertions, and occasionally triggering the event. For example, in Chapter 19, we exploited jMock’s facility for setting multiple expectation blocks to write a expectSniperToFailWhenItIs() method that wraps up repeated behavior behind a descriptive name.

分解测试结构时唯一需要注意的是,正如我们在本章的介绍中所说,我们必须小心,不要让测试过于抽象,以至于我们无法再看到它的作用。我们最关心的是让测试描述目标代码的作用,因此我们会进行足够的重构,以便能够看到它的流程,但我们并不总是像对生产代码那样努力地重构。

The only caution with factoring out test structure is that, as we said in the introduction to this chapter, we have to be careful not to make a test so abstract that we cannot see what it does any more. Our highest concern is making the test describe what the target code does, so we refactor enough to be able to see its flow, but we don’t always refactor as hard as we would for production code.

强调积极因素

Accentuate the Positive

我们只有在想断言异常时才会在测试中捕获异常。我们有时会看到这样的测试:

We only catch exceptions in a test if we want to assert something about them. We sometimes see tests like this:

@Test public void expandsMacrosSurroundedWithBraces() {

StringTemplate template = new StringTemplate("{a}{b}");



try {

String expand = template.expand(macros);

assertThat(expanded, equalTo("AB"));

} catch (TemplateFormatException e) {

fail("模板失败:" + e);

}

}

@Test public void expandsMacrosSurroundedWithBraces() {

StringTemplate template = new StringTemplate("{a}{b}");



try {

String expanded = template.expand(macros);

assertThat(expanded, equalTo("AB"));

} catch (TemplateFormatException e) {

fail("Template failed: " + e);

}

}

如果此测试旨在通过​​,那么转换异常实际上会从堆栈跟踪中删除信息。最简单的做法是让异常传播以供测试运行时捕获。我们可以将任意异常添加到测试方法签名中,因为它仅通过反射调用。这至少会删除测试的一半行,我们可以将其进一步压缩为:

If this test is intended to pass, then converting the exception actually drops information from the stack trace. The simplest thing to do is to let the exception propagate for the test runtime to catch. We can add arbitrary exceptions to the test method signature because it’s only called by reflection. This removes at least half the lines of the test, and we can compact it further to be:

@Test public void expandsMacrosSurroundedWithBraces()抛出异常{

assertThat(new StringTemplate("{a}{b}").expand(macros),

equalTo("AB"));

}

@Test public void expandsMacrosSurroundedWithBraces() throws Exception {

assertThat(new StringTemplate("{a}{b}").expand(macros),

equalTo("AB"));

}

它只告诉我们应该发生什么,而忽略其他一切。

which tells us just what is supposed to happen and ignores everything else.

委托给下属对象

Delegate to Subordinate Objects

有时辅助方法是不够的,我们需要辅助对象来支持测试。我们在第 11 章中构建的测试装置中看到了这一点。我们开发了ApplicationRunnerAuctionSniperDriverFakeAuctionServer类,以便我们可以根据拍卖和狙击手编写测试,而不是根据 Swing 和消息传递编写测试。

Sometimes helper methods aren’t enough and we need helper objects to support the tests. We saw this in the test rig we built in Chapter 11. We developed the ApplicationRunner, AuctionSniperDriver, and FakeAuctionServer classes so we could write tests in terms of auctions and Snipers, not in terms of Swing and messaging.

一种更常见的技术是编写测试数据构建器来构建复杂的数据结构,只使用适合测试的值;有关详细信息,请参阅第 22 章。同样,重点是只在测试中包含相关的值,以便读者能够理解意图;其他所有内容都可以默认。

A more common technique is to write test data builders to build up complex data structures with just the appropriate values for a test; see Chapter 22 for more detail. Again, the point is to include in the test just the values that are relevant, so that the reader can understand the intent; everything else can be defaulted.

编写从属对象有两种方法。在第 11 章中,我们首先编写了想要看到的测试,然后填写支持对象:从问题的陈述开始,看看它会走向何方。另一种方法是直接在测试中编写代码,然后重构任何行为集群。这就是 WindowLicker 框架的起源,它最初是 JUnit 测试中用于与 Swing 事件调度程序交互的辅助代码,最终发展成为一个单独的项目。

There are two approaches to writing subordinate objects. In Chapter 11 we started by writing the test we wanted to see and then filling in the supporting objects: start from a statement of the problem and see where it goes. The alternative is to write the code directly in the tests, and then refactor out any clusters of behavior. This is the origin of the WindowLicker framework, which started out as helper code in JUnit tests for interacting with the Swing event dispatcher and eventually grew into a separate project.

断言和期望

Assertions and Expectations

测试的断言和期望应该准确地传达目标代码行为中重要的内容。我们经常看到测试断言的代码细节太多,这使得它们难以阅读,并且当情况发生变化时变得脆弱;我们在“太多期望”(第 242页)中讨论了这可能意味着什么。

The assertions and expectations of a test should communicate precisely what matters in the behavior of the target code. We regularly see code where tests assert too much detail, which makes them difficult to read and brittle when things change; we discuss what this might mean in “Too Many Expectations” (page 242).

对于我们编写的期望和断言,我们尝试使它们的定义尽可能狭窄。例如,在上面的“带有价格的工具”断言中,我们仅检查执行价格,并忽略其余值,因为它们在该测试中无关紧要。在其他情况下,我们对方法的所有参数都不感兴趣,因此我们在期望中忽略它们。在第 19 章中,我们定义了一个期望,表示我们关心狙击手标识符和消息,但任何RuntimeException对象都可以用于第三个参数:

For the expectations and assertions we write, we try to keep them as narrowly defined as possible. For example, in the “instrument with price” assertion above, we check only the strike price and ignore the rest of the values as irrelevant in that test. In other cases, we’re not interested in all of the arguments to a method, so we ignore them in the expectation. In Chapter 19, we define an expectation that says that we care about the Sniper identifier and message, but that any RuntimeException object will do for the third argument:

其中之一(failureReporter).cannotTranslateMessage(

带有(SNIPER_ID),带有(badMessage),

带有(任何(RuntimeException.class)));

oneOf(failureReporter).cannotTranslateMessage(

with(SNIPER_ID), with(badMessage),

with(any(RuntimeException.class)));

如果你在大学期间学习过前提条件和后置条件,那么这次培训将会派上用场。

If you learned about pre- and postconditions in college, this is when that training will come in useful.

最后,需要注意的是assertFalse()。失败消息和否定的组合很容易让人误以为这两个日期应该不同:

Finally, a word of caution on assertFalse(). The combination of the failure message and negation makes it easy to read this as meaning that the two dates should not be different:

assertFalse("结束日期",first.endDate().equals(second.endDate()));

assertFalse("end date", first.endDate().equals(second.endDate()));

我们可以使用assertTrue()并在结果中添加“!”,但同样,单个字符很容易被忽略。这就是为什么我们更喜欢使用匹配器来使代码更明确:

We could use assertTrue() and add a “!” to the result but, again, the single character is easy to miss. That’s why we prefer to use matchers to make the code more explicit:

assertThat("结束日期",first.endDate(),not(equalTo(second.endDate())));

assertThat("end date", first.endDate(), not(equalTo(second.endDate())));

这也具有在故障报告中显示实际收到的日期的优点:

which also has the advantage of showing the actual date received in the failure report:

java.lang.AssertionError:结束日期

预期:不是 <Thu Jan 01 02:34:38 GMT 1970>

但是:是 <Thu Jan 01 02:34:38 GMT 1970>

java.lang.AssertionError: end date

Expected: not <Thu Jan 01 02:34:38 GMT 1970>

but: was <Thu Jan 01 02:34:38 GMT 1970>

文字和变量

Literals and Variables

最后一点。正如我们在本章的介绍中所写,测试代码往往比生产代码更具体,这意味着它具有更多的文字值。没有解释的文字值可能难以理解,因为程序员必须解释某个特定值是否重要(例如刚好超出允许范围)或只是一个用于跟踪行为的任意占位符(例如应该加倍并传递给同行)。文字值不描述其角色,尽管有一些技术可以做到这一点,我们将在第 23 章中展示

One last point. As we wrote in the introduction to this chapter, test code tends to be more concrete than production code, which means it has more literal values. Literal values without explanation can be difficult to understand because the programmer has to interpret whether a particular value is significant (e.g. just outside the allowed range) or just an arbitrary placeholder to trace behavior (e.g. should be doubled and passed on to a peer). A literal value does not describe its role, although there are some techniques for doing so that we will show in Chapter 23

一种解决方案是将字面值分配给变量和常量,并使用描述其功能的名称。例如,在第 12 章中,我们声明

One solution is to allocate literal values to variables and constants with names that describe their function. For example, in Chapter 12 we declared

公共静态最终聊天 UNUSED_CHAT = null;

public static final Chat UNUSED_CHAT = null;

表明我们用来null表示目标代码中未使用的参数。我们并不期望代码null在生产中收到,但事实证明我们并不关心,这让测试更容易。同样,团队可能会制定命名通用值的约定,例如:

to show that we were using null to represent an argument that was unused in the target code. We weren’t expecting the code to receive null in production, but it turns out that we don’t care and it makes testing easier. Similarly, a team might develop conventions for naming common values, such as:

公共最终静态INVALID_ID = 666;

public final static INVALID_ID = 666;

我们命名变量来显示这些值或对象在测试中扮演的角色以及它们与目标对象的关系。

We name variables to show the roles these values or objects play in the test and their relationships to the target object.

第 22 章构建复杂测试数据

Chapter 22. Constructing Complex Test Data

很多沟通的尝试都因为说得太多而失败。

Many attempts to communicate are nullified by saying too much.

—罗伯特·格林利夫

—Robert Greenleaf

介绍

Introduction

如果我们严格控制构造函数和不可变值对象的使用,那么在测试中构造对象可能是一件苦差事。在生产代码中,我们在相对较少的地方构造此类对象,并且所有必需的值都可以从用户输入、数据库查询或收到的消息等中获得。然而,在测试中,每次我们想要创建对象时,我们都必须提供所有构造函数参数:

If we are strict about our use of constructors and immutable value objects, constructing objects in tests can be a chore. In production code, we construct such objects in relatively few places and all the required values are available to hand from, for example, user input, a database query, or a received message. In tests, however, we have to provide all the constructor arguments every time we want to create an object:

@Test public void chargeCustomerForTotalCostOfAllOrderedItems() {

Order order = new Order(

new Customer("Sherlock Holmes",

new Address("221b Baker Street",

"London",

new PostCode("NW1", "3RX"))));

order.addLine(new OrderLine("Deerstalker Hat", 1));

order.addLine(new OrderLine("Tweed Cape", 1));

[...]

}

@Test public void chargesCustomerForTotalCostOfAllOrderedItems() {

Order order = new Order(

new Customer("Sherlock Holmes",

new Address("221b Baker Street",

"London",

new PostCode("NW1", "3RX"))));

order.addLine(new OrderLine("Deerstalker Hat", 1));

order.addLine(new OrderLine("Tweed Cape", 1));

[...]

}

创建所有这些对象的代码使测试难以阅读,因为它们充满了对测试行为没有帮助的信息。它还使测试变得脆弱,因为对构造函数参数或对象结构的更改将破坏许多测试。对象母体模式[Schuh01]是避免此问题的一种尝试。对象母体是一个包含许多工厂方法[Gamma94]的类,这些工厂方法创建用于测试的对象。例如,我们可以为订单编写一个对象母体:

The code to create all these objects makes the tests hard to read, filling them with information that doesn’t contribute to the behavior being tested. It also makes tests brittle, as changes to the constructor arguments or the structure of the objects will break many tests. The object mother pattern [Schuh01] is one attempt to avoid this problem. An object mother is a class that contains a number of factory methods [Gamma94] that create objects for use in tests. For example, we could write an object mother for orders:

订单 order = ExampleOrders.newDeerstalkerAndCapeOrder();

Order order = ExampleOrders.newDeerstalkerAndCapeOrder();

对象母体通过打包创建新对象结构的代码并为其命名,使测试更具可读性。它还有助于维护,因为它的功能可以在测试之间重复使用。另一方面,对象母模式不能很好地应对测试数据的变化——每个细微的差异都需要一个新的工厂方法:

An object mother makes tests more readable by packaging up the code that creates new object structures and giving it a name. It also helps with maintenance since its features can be reused between tests. On the other hand, the object mother pattern does not cope well with variation in the test data—every minor difference requires a new factory method:

订单 order1 = ExampleOrders.newDeerstalkerAndCapeAndSwordstickOrder();

订单 order2 = ExampleOrders.newDeerstalkerAndBootsOrder();

[...]

Order order1 = ExampleOrders.newDeerstalkerAndCapeAndSwordstickOrder();

Order order2 = ExampleOrders.newDeerstalkerAndBootsOrder();

[...]

随着时间的推移,对象母体本身可能会变得过于混乱而无法支持,要么充满重复的代码,要么重构为无数细粒度的方法。

Over time, an object mother may itself become too messy to support, either full of duplicated code or refactored into an infinity of fine-grained methods.

测试数据构建器

Test Data Builders

另一种解决方案是使用构建器模式在测试中构建实例,通常用于值。对于需要复杂设置的类,我们创建一个测试数据构建器,它为每个构造函数参数都有一个字段,并初始化为安全值。构建器具有“可链接”的公共方法,用于覆盖其字段中的值,并且按照惯例,build()最后调用一个方法来根据字段值创建目标对象的新实例。1一个可选的改进是为构建器本身添加一个静态工厂方法,以便在测试中更清楚地了解正在构建的内容。例如,Order对象的构建器可能如下所示:

Another solution is to use the builder pattern to build instances in tests, most often for values. For a class that requires complex setup, we create a test data builder that has a field for each constructor parameter, initialized to a safe value. The builder has “chainable” public methods for overwriting the values in its fields and, by convention, a build() method that is called last to create a new instance of the target object from the field values.1 An optional refinement is to add a static factory method for the builder itself so that it’s clearer in the test what is being built. For example, a builder for Order objects might look like:

1.这种模式本质上与 Smalltalk 级联相同。

1. This pattern is essentially the same as a Smalltalk cascade.

公共类 OrderBuilder {

私有客户 客户 = 新客户生成器 (). build ();

私有列表 <OrderLine> 行 = 新 ArrayList <OrderLine> ();

私有 BigDecimal 折扣率 = BigDecimal. ZERO;



公共静态 OrderBuilder anOrder () {

返回新 OrderBuilder ();

}

公共 OrderBuilder withCustomer (客户 客户) {

this.customer = 客户;

返回此;

}

公共 OrderBuilder withOrderLines (OrderLines 行) {

this.lines = 行;

返回此;

}

公共 OrderBuilder withDiscount (BigDecimal 折扣率) {

this.discountRate = 折扣率;

返回此;

}

公共订单 build () {

订单 订单 = 新订单 (客户);

对于 (OrderLine 行:行) 订单. addLine (行);

订单. setDiscountRate (折扣率);

}

}

}

public class OrderBuilder {

private Customer customer = new CustomerBuilder().build();

private List<OrderLine> lines = new ArrayList<OrderLine>();

private BigDecimal discountRate = BigDecimal.ZERO;



public static OrderBuilder anOrder() {

return new OrderBuilder();

}

public OrderBuilder withCustomer(Customer customer) {

this.customer = customer;

return this;

}

public OrderBuilder withOrderLines(OrderLines lines) {

this.lines = lines;

return this;

}

public OrderBuilder withDiscount(BigDecimal discountRate) {

this.discountRate = discountRate;

return this;

}

public Order build() {

Order order = new Order(customer);

for (OrderLine line : lines) order.addLine(line);

order.setDiscountRate(discountRate);

}

}

}

只需要一个对象而不关心其内容的测试Order可以在一行中创建一个:

Tests that just need an Order object and are not concerned with its contents can create one in a single line:

订单 order = new OrderBuilder().build();

Order order = new OrderBuilder().build();

需要对象内特定值的测试可以仅指定相关值,其余值使用默认值。这使测试更具表现力,因为它仅包含与预期结果相关的值。例如,如果测试需要没有邮政编码的 a,我们会写OrderCustomer

Tests that need particular values within an object can specify just those values that are relevant and use defaults for the rest. This makes the test more expressive because it includes only the values that are relevant to the expected results. For example, if a test needed an Order for a Customer with no postcode, we would write:

新的OrderBuilder()

。fromCustomer(

新的CustomerBuilder()

。withAddress(新的AddressBuilder()。withNoPostcode()。build())。

build())

。build();

new OrderBuilder()

.fromCustomer(

new CustomerBuilder()

.withAddress(new AddressBuilder().withNoPostcode().build())

.build())

.build();

我们发现测试数据构建器有助于保持测试的表达力和对变化的适应性。首先,它们在创建新对象时会包装大部分语法噪音。其次,它们使默认情况变得简单,特殊情况也不会变得太复杂。第三,它们保护测试免受对象结构变化的影响。如果我们向构造函数添加一个参数,那么我们只需更改相关的构建器和那些需要新参数的测试。

We find that test data builders help keep tests expressive and resilient to change. First, they wrap up most of the syntax noise when creating new objects. Second, they make the default case simple, and special cases not much more complicated. Third, they protect the test against changes in the structure of its objects. If we add an argument to a constructor, then all we have to change is the relevant builder and those tests that drove the need for the new argument.

最后一个好处是,我们可以编写更易于阅读和发现错误的测试代码,因为每个构建器方法都标识了其参数的用途。例如,在此代码中,不明显“伦敦”作为第二条街道线而不是城市名称传入:

A final benefit is that we can write test code that’s easier to read and spot errors, because each builder method identifies the purpose of its parameter. For example, in this code it’s not obvious that “London” has been passed in as the second street line rather than the city name:

TestAddresses.newAddress("221b Baker Street", "伦敦", "NW1 6XE");

TestAddresses.newAddress("221b Baker Street", "London", "NW1 6XE");

测试数据生成器使错误更加明显:

A test data builder makes the mistake more obvious:

新的 AddressBuilder()

.withStreet("221b 贝克街")

.withStreet2("伦敦")

.withPostCode("NW1 6XE")

.build();

new AddressBuilder()

.withStreet("221b Baker Street")

.withStreet2("London")

.withPostCode("NW1 6XE")

.build();

创建类似对象

Creating Similar Objects

当我们需要创建多个相似的对象时,我们可以使用构建器。最明显的方法是为每个新对象创建一个新的构建器,但这会导致重复并使测试代码更难处理。例如,除了折扣外,这两个订单完全相同。如果我们不突出差异,就很难发现:

We can use builders when we need to create multiple similar objects. The most obvious approach is to create a new builder for each new object, but this leads to duplication and makes the test code harder to work with. For example, these two orders are identical apart from the discount. If we didn’t highlight the difference, it would be difficult to find:

订单 orderWithSmallDiscount = new OrderBuilder()

.withLine("猎鹿帽", 1)

.withLine("粗花呢斗篷", 1)

.withDiscount( 0.10 )

.build();



订单 orderWithLargeDiscount = new OrderBuilder()

.withLine("猎鹿帽", 1)

.withLine("粗花呢斗篷", 1)

.withDiscount( 0.25 )

.build();

Order orderWithSmallDiscount = new OrderBuilder()

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1)

.withDiscount(0.10)

.build();



Order orderWithLargeDiscount = new OrderBuilder()

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1)

.withDiscount(0.25)

.build();

相反,我们可以用公共状态初始化单个构建器,然后对于要构建的每个对象定义不同的值并调用其build()方法:

Instead, we can initialize a single builder with the common state and then, for each object to be built, define the differing values and call its build() method:

OrderBuilder hatAndCape = new OrderBuilder()

.withLine("猎鹿帽", 1)

.withLine("粗花呢斗篷", 1);



订单 orderWithSmallDiscount = hatAndCape.withDiscount(0.10).build();

订单 orderWithLargeDiscount = hatAndCape.withDiscount(0.25).build();

OrderBuilder hatAndCape = new OrderBuilder()

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1);



Order orderWithSmallDiscount = hatAndCape.withDiscount(0.10).build();

Order orderWithLargeDiscount = hatAndCape.withDiscount(0.25).build();

这样可以用更少的代码生成更集中的测试。我们可以用共同的特征来命名构建器,用差异来命名域对象。

This produces a more focused test with less code. We can name the builder after the features that are common, and the domain objects after their differences.

如果对象因相同字段而不同,则此技术效果最佳。如果对象因不同字段而不同,则每个对象build()都会从以前的使用中获取更改。例如,此代码中不明显会orderWithGiftVoucher包含 10% 折扣和礼券:

This technique works best if the objects differ by the same fields. If the objects vary by different fields, each build() will pick up the changes from the previous uses. For example, it’s not obvious in this code that orderWithGiftVoucher will carry the 10% discount as well as a gift voucher:

订单 orderWithDiscount = hatAndCape. withDiscount(0.10) .build();

订单 orderWithGiftVoucher = hatAndCape. withGiftVoucher("abc") .build();

Order orderWithDiscount = hatAndCape.withDiscount(0.10).build();

Order orderWithGiftVoucher = hatAndCape.withGiftVoucher("abc").build();

为了避免这个问题,我们可以添加一个复制构造函数或一个从另一个构建器复制状态的方法:

To avoid this problem, we could add a copy constructor or a method that duplicates the state from another builder:

订单 orderWithDiscount = new OrderBuilder(hatAndCape)

.withDiscount(0.10)

.build();



订单 orderWithGiftVoucher = new OrderBuilder(hatAndCape)

.withGiftVoucher("abc")

.build();

Order orderWithDiscount = new OrderBuilder(hatAndCape)

.withDiscount(0.10)

.build();



Order orderWithGiftVoucher = new OrderBuilder(hatAndCape)

.withGiftVoucher("abc")

.build();

或者,我们可以添加一个工厂方法,返回具有其当​​前状态的构建器的副本:

Alternatively, we could add a factory method that returns a copy of the builder with its current state:

订单 orderWithDiscount = hatAndCape。但() .withDiscount(0.10).build();

订单 orderWithGiftVoucher = hatAndCape。但() .withGiftVoucher("abc").build();

Order orderWithDiscount = hatAndCape.but().withDiscount(0.10).build();

Order orderWithGiftVoucher = hatAndCape.but().withGiftVoucher("abc").build();

对于复杂的设置,最安全的选择是使“with”方法发挥作用,并让每个方法返回构建器的新副本而不是其自身。

For complex setups, the safest option is to make the “with” methods functional and have each one return a new copy of the builder instead of itself.

结合建造者

Combining Builders

当对象的测试数据构建器使用其他“构建”对象时,我们可以将这些构建器作为参数而不是其对象传递。这将通过删除build()方法来简化测试代码。结果更易于阅读,因为它强调了重要信息(正在构建的内容),而不是构建它的机制。例如,此代码构建了一个没有邮政编码的订单,但它由构建器基础结构主导:

Where a test data builder for an object uses other “built” objects, we can pass in those builders as arguments rather than their objects. This will simplify the test code by removing the build() methods. The result is easier to read because it emphasizes the important information—what is being built—rather than the mechanics of building it. For example, this code builds an order with no postcode, but it’s dominated by the builder infrastructure:

订单 orderWithNoPostcode = new OrderBuilder()

.fromCustomer(

new CustomerBuilder()

.withAddress(new AddressBuilder().withNoPostcode().build())

.build())

.build();

Order orderWithNoPostcode = new OrderBuilder()

.fromCustomer(

new CustomerBuilder()

.withAddress(new AddressBuilder().withNoPostcode().build())

.build())

.build();

我们可以通过轮流使用建造者来消除大部分噪音:

We can remove much of the noise by passing around builders:

订单 order = new OrderBuilder()

.fromCustomer(

new CustomerBuilder()

.withAddress(new AddressBuilder().withNoPostcode())))

.build();

Order order = new OrderBuilder()

.fromCustomer(

new CustomerBuilder()

.withAddress(new AddressBuilder().withNoPostcode())))

.build();

使用工厂方法强调领域模型

Emphasizing the Domain Model with Factory Methods

我们可以通过将构建器的构造包装在工厂方法中来进一步减少测试代码中的噪音:

We can further reduce the noise in the test code by wrapping up the construction of the builders in factory methods:

订单 order =

anOrder() .fromCustomer(

aCustomer() .withAddress( anAddress() .withNoPostcode())).build();

Order order =

anOrder().fromCustomer(

aCustomer().withAddress(anAddress().withNoPostcode())).build();

随着我们压缩测试代码,构建器中的重复变得更加突出;我们在“with”和“builder”方法中都有构造类型的名称。我们可以利用 Java 的方法重载,将其折叠为单个with()方法,让 Java 类型系统确定要更新哪个字段:

As we compress the test code, the duplication in the builders becomes more obtrusive; we have the name of the constructed type in both the “with” and “builder” methods. We can take advantage of Java’s method overloading by collapsing this to a single with() method, letting the Java type system figure out which field to update:

订单 order =

anOrder(). from (aCustomer(). with (anAddress().withNoPostcode())).build();

Order order =

anOrder().from(aCustomer().with(anAddress().withNoPostcode())).build();

显然,这只适用于每种类型一个参数的情况。例如,如果我们引入一个Postcode,我们可以使用重载,而其余的构建器方法必须有明确的名称,因为它们使用String

Obviously, this will only work with one argument of each type. For example, if we introduce a Postcode, we can use overloading, whereas the rest of the builder methods must have explicit names because they use String:

地址 aLongerAddress = anAddress()

.withStreet("221b Baker Street")

.withCity("伦敦")

.with( postCode ("NW1", "3RX"))

.build();

Address aLongerAddress = anAddress()

.withStreet("221b Baker Street")

.withCity("London")

.with(postCode("NW1", "3RX"))

.build();

这应该鼓励我们引入域类型,正如我们在“域类型比字符串更好”(第 213页)中所写,域类型可以产生更具表现力和更易于维护的代码。

This should encourage us to introduce domain types, which, as we wrote in “Domain Types Are Better Than Strings” (page 213), leads to more expressive and maintainable code.

在使用点消除重复

Removing Duplication at the Point of Use

通过使用测试数据构建器,我们使组装复杂测试对象的过程变得更简单、更具表现力。现在,让我们看看如何构建测试,以便在上下文中充分利用这些构建器。我们经常发现自己用类似的代码编写测试来创建支持对象并将它们传递给被测代码,所以我们想清除这种重复。我们发现一些重构比其他重构更好;下面是一个例子。

We’ve made the process of assembling complex objects for tests simpler and more expressive by using test data builders. Now, let’s look at how we can structure our tests to make the best use of these builders in context. We often find ourselves writing tests with similar code to create supporting objects and pass them to the code under test, so we want to clean up this duplication. We’ve found that some refactorings are better than others; here’s an example.

首先,删除重复项

First, Remove Duplication

我们有一个异步处理订单的系统。测试将订单输入系统,在监视器上跟踪其进度,然后在用户界面中查找它们。我们已打包所有基础架构,因此测试如下所示:

We have a system that processes orders asynchronously. The test feeds orders into the system, tracks their progress on a monitor, and then looks for them in a user interface. We’ve packaged up all the infrastructure so the test looks like this:

@Test public void reportsTotalSalesOfOrderedProducts() {

Order order1 = anOrder()

.withLine("猎鹿帽", 1)

.withLine("粗花呢斗篷", 1)

.withCustomersReference(1234)

.build();

requestSender.send(order1);

progressMonitor.waitForCompletion(order1);



Order order2 = anOrder()

.withLine("猎鹿帽", 1)

.withCustomersReference(5678)

.build();

requestSender.send(order2);

progressMonitor.waitForCompletion(order2);



TotalSalesReport report = gui.openSalesReport();

report.checkDisplayedTotalSalesFor("猎鹿帽", is(equalTo(2)));

report.checkDisplayedTotalSalesFor("粗花呢斗篷", is(equalTo(1)));

}

@Test public void reportsTotalSalesOfOrderedProducts() {

Order order1 = anOrder()

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1)

.withCustomersReference(1234)

.build();

requestSender.send(order1);

progressMonitor.waitForCompletion(order1);



Order order2 = anOrder()

.withLine("Deerstalker Hat", 1)

.withCustomersReference(5678)

.build();

requestSender.send(order2);

progressMonitor.waitForCompletion(order2);



TotalSalesReport report = gui.openSalesReport();

report.checkDisplayedTotalSalesFor("Deerstalker Hat", is(equalTo(2)));

report.checkDisplayedTotalSalesFor("Tweed Cape", is(equalTo(1)));

}

订单的创建、发送和跟踪方式明显存在重复。我们的第一个想法可能是将其放入辅助方法中:

There’s an obvious duplication in the way the orders are created, sent, and tracked. Our first thought might be to pull that into a helper method:

@Test public void reportsTotalSalesOfOrderedProducts() {

submitOrderFor ("猎鹿帽", "粗花呢斗篷");

submitOrderFor ("猎鹿帽");



TotalSalesReport report = gui.openSalesReport();

report.checkDisplayedTotalSalesFor("猎鹿帽", is(equalTo(2)));

report.checkDisplayedTotalSalesFor("粗花呢斗篷", is(equalTo(1)));

}

void submitOrderFor(String ... products) {

OrderBuilder orderBuilder = anOrder()

.withCustomersReference(nextCustomerReference());



for (String product : products) {

orderBuilder = orderBuilder.withLine(product, 1);

}



Order order = orderBuilder.build();

requestSender.send(order);

progressMonitor.waitForCompletion(order);

}

@Test public void reportsTotalSalesOfOrderedProducts() {

submitOrderFor("Deerstalker Hat", "Tweed Cape");

submitOrderFor("Deerstalker Hat");



TotalSalesReport report = gui.openSalesReport();

report.checkDisplayedTotalSalesFor("Deerstalker Hat", is(equalTo(2)));

report.checkDisplayedTotalSalesFor("Tweed Cape", is(equalTo(1)));

}

void submitOrderFor(String ... products) {

OrderBuilder orderBuilder = anOrder()

.withCustomersReference(nextCustomerReference());



for (String product : products) {

orderBuilder = orderBuilder.withLine(product, 1);

}



Order order = orderBuilder.build();

requestSender.send(order);

progressMonitor.waitForCompletion(order);

}

这种重构在只有一种情况时效果很好,但就像对象母模式一样,在有变化时扩展性不好。当我们处理具有不同内容、修改、取消等的订单时,我们最终会陷入这种混乱:

This refactoring works fine when there’s a single case but, like the object mother pattern, does not scale well when we have variation. As we deal with orders with different contents, amendments, cancellations, and so on, we end up with this sort of mess:

void submitOrderFor(String ... products) { [...]

void submitOrderFor(String product, int count,

String otherProduct, int otherCount) { [...]

void submitOrderFor(String product, double discount) { [...]

void submitOrderFor(String product, String giftVoucherCode) { [...]

void submitOrderFor(String ... products) { [...]

void submitOrderFor(String product, int count,

String otherProduct, int otherCount) { [...]

void submitOrderFor(String product, double discount) { [...]

void submitOrderFor(String product, String giftVoucherCode) { [...]

我们仔细思考了测试之间的差异和共同点,并意识到更好的替代方案是传递构建器,而不是传递其参数;这与我们开始组合构建器时的情况类似。辅助方法可以使用构建器在订单输入系统之前向订单添加任何支持细节:

We think a bit harder about what varies between tests and what is common, and realize that a better alternative is to pass the builder through, not its arguments; it’s similar to when we started combining builders. The helper method can use the builder to add any supporting detail to the order before feeding it into the system:

@Test public void reportsTotalSalesOfOrderedProducts() {

sendAndProcess (anOrder()

.withLine("猎鹿帽", 1)

.withLine("粗花呢斗篷", 1));

sendAndProcess (anOrder()

.withLine("猎鹿帽", 1));



TotalSalesReport report = gui.openSalesReport();

report.checkDisplayedTotalSalesFor("猎鹿帽", is(equalTo(2)));

report.checkDisplayedTotalSalesFor("粗花呢斗篷", is(equalTo(1)));

}



void sendAndProcess (OrderBuilder orderDetails) {

Order order = orderDetails

.withDefaultCustomersReference(nextCustomerReference())

.build();

requestSender.send(order);

progressMonitor.waitForCompletion(order);

}

@Test public void reportsTotalSalesOfOrderedProducts() {

sendAndProcess (anOrder()

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1));

sendAndProcess (anOrder()

.withLine("Deerstalker Hat", 1));



TotalSalesReport report = gui.openSalesReport();

report.checkDisplayedTotalSalesFor("Deerstalker Hat", is(equalTo(2)));

report.checkDisplayedTotalSalesFor("Tweed Cape", is(equalTo(1)));

}



void sendAndProcess(OrderBuilder orderDetails) {

Order order = orderDetails

.withDefaultCustomersReference(nextCustomerReference())

.build();

requestSender.send(order);

progressMonitor.waitForCompletion(order);

}

然后,提高游戏水平

Then, Raise the Game

测试代码看起来好多了,但读起来仍然像脚本。我们可以通过重新措辞一些名称,将其重点转移到预期的行为上,而不是测试如何实现:

The test code is looking better, but it still reads like a script. We can change its emphasis to what behavior is expected, rather than how the test is implemented, by rewording some of the names:

@Test public void reportsTotalSalesOfOrderedProducts() {

havingReceived (anOrder()

.withLine("猎鹿帽", 1)

.withLine("粗花呢斗篷", 1));

havingReceived (anOrder()

.withLine("猎鹿帽", 1));



TotalSalesReport report = gui.openSalesReport(); report.displaysTotalSalesFor

( "猎鹿帽", equalTo(2)); report.displaysTotalSalesFor ("粗花呢斗篷", equalTo(1)); } @Test public void takesAmendmentsIntoAccountWhenCalculatingTotalSales() { Customer theCustomer = aCustomer().build(); havingReceived (anOrder().from(theCustomer) .withLine("猎鹿帽", 1) .withLine("粗花呢斗篷", 1)); havingReceived (anOrderAmendment().from(theCustomer) .withLine ("猎鹿帽", 2)); TotalSalesReport report = user.openSalesReport(); report.containsTotalSalesFor ("猎鹿帽", equalTo(2)); report.containsTotalSalesFor ("粗花呢斗篷", equalTo(1)); }

































@Test public void reportsTotalSalesOfOrderedProducts() {

havingReceived (anOrder()

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1));

havingReceived (anOrder()

.withLine("Deerstalker Hat", 1));



TotalSalesReport report = gui.openSalesReport();

report.displaysTotalSalesFor("Deerstalker Hat", equalTo(2));

report.displaysTotalSalesFor("Tweed Cape", equalTo(1));

}



@Test public void takesAmendmentsIntoAccountWhenCalculatingTotalSales() {

Customer theCustomer = aCustomer().build();



havingReceived(anOrder().from(theCustomer)

.withLine("Deerstalker Hat", 1)

.withLine("Tweed Cape", 1));



havingReceived(anOrderAmendment().from(theCustomer)

.withLine("Deerstalker Hat", 2));



TotalSalesReport report = user.openSalesReport();

report.containsTotalSalesFor("Deerstalker Hat", equalTo(2));

report.containsTotalSalesFor("Tweed Cape", equalTo(1));

}

我们从一个看似程序化的测试开始,将其部分行为提取到构建器对象中,最后得到该功能功能的声明性描述。我们正在将测试代码推向与其他人(甚至是非技术人员)讨论该功能时可以使用的语言;我们将其他所有内容都推到支持代码中。

We started with a test that looked procedural, extracted some of its behavior into builder objects, and ended up with a declarative description of what the feature does. We’re nudging the test code towards the sort of language we could use when discussing the feature with someone else, even someone non-technical; we push everything else into supporting code.

沟通第一

Communication First

我们使用测试数据构建器来减少重复并使测试代码更具表现力。这是另一种反映我们对代码语言的痴迷的技术,其驱动力是代码就是用来阅读的。结合工厂方法和测试脚手架,测试数据构建器可帮助我们编写更具文采、更具声明性的测试,这些测试描述了功能的目的,而不仅仅是驱动功能的一系列步骤。

We use test data builders to reduce duplication and make the test code more expressive. It’s another technique that reflects our obsession with the language of code, driven by the principle that code is there to be read. Combined with factory methods and test scaffolding, test data builders help us write more literate, declarative tests that describe the intention of a feature, not just a sequence of steps to drive it.

使用这些技术,我们甚至可以使用更高级别的测试直接与非技术利益相关者(例如业务分析师)进行沟通。如果他们愿意为了忽略模糊的标点符号,我们可以使用测试来帮助我们缩小功能应该做什么以及为什么做的范围。

Using these techniques, we can even use higher-level tests to communicate directly with non-technical stakeholders, such as business analysts. If they’re willing to ignore the obscure punctuation, we can use the tests to help us narrow down exactly what a feature should do, and why.

还有其他工具旨在促进团队中技术和非技术成员之间的协作,例如 FIT [Mugridge05] 。我们发现,就像 LiFT 团队[LIFT]等其他人一样,我们可以在不脱离开发工具集的情况下实现大部分目标 — 当然,我们可以为自己编写更好的测试。

There are other tools that are designed to foster collaboration across the technical and non-technical members in a team, such as FIT [Mugridge05]. We’ve found, as have others such as the LiFT team [LIFT], that we can achieve much of this while staying within our development toolset—and, of course, we can write better tests for ourselves.

第 23 章 测试诊断

Chapter 23. Test Diagnostics

错误是发现的门户。

Mistakes are the portals of discovery.

—詹姆斯·乔伊斯

—James Joyce

设计注定失败

Design to Fail

测试的目的不在于通过,而在于失败。我们希望生产代码能够通过测试,但我们也希望测试能够检测并报告任何确实存在的错误。“失败”的测试实际上已经成功完成了它被设计用来做的工作。即使是与我们工作无关的领域的意外测试失败也可能很有价值,因为它们揭示了我们未曾注意到的代码中的隐含关系。

The point of a test is not to pass but to fail. We want the production code to pass its tests, but we also want the tests to detect and report any errors that do exist. A “failing” test has actually succeeded at the job it was designed to do. Even unexpected test failures, in an area unrelated to where we are working, can be valuable because they reveal implicit relationships in the code that we hadn’t noticed.

然而,我们想要避免的一种情况是无法诊断已经发生的测试失败。我们最不应该做的事情就是打开调试器并逐步执行测试代码以找到不一致之处。至少,这表明我们的测试还没有足够清楚地表达我们的需求。在最坏的情况下,我们会发现自己陷入“调试地狱”,有最后期限要满足,但不知道修复需要多长时间。此时,删除测试的诱惑很大——从而失去我们的安全网。

One situation we want to avoid, however, is when we can’t diagnose a test failure that has happened. The last thing we should have to do is crack open the debugger and step through the tested code to find the point of disagreement. At a minimum, it suggests that our tests don’t yet express our requirements clearly enough. In the worst case, we can find ourselves in “debug hell,” with deadlines to meet but no idea of how long a fix will take. At this point, the temptation will be high to just delete the test—and lose our safety net.

留在家附近

Stay Close to Home

图像

频繁与源代码存储库同步(最多每隔几分钟一次),这样如果测试意外失败,则恢复最近的更改并尝试其他方法不会花费太多。

Synchronize frequently with the source code repository—up to every few minutes—so that if a test fails unexpectedly it won’t cost much to revert your recent changes and try another approach.

本技巧的另一个含义是不要太拘谨,不要放弃代码并重试。有时回滚并清醒地重新启动比继续挖掘更快。

The other implication of this tip is not to be too inhibited about dropping code and trying again. Sometimes it’s quicker to roll back and restart with a clear head than to keep digging.

我们已经学会了如何让测试失败信息丰富。如果失败的测试清楚地解释了失败的原因,我们就可以快速诊断和纠正代码。然后,我们就可以继续下一个任务了。

We’ve learned the hard way to make tests fail informatively. If a failing test clearly explains what has failed and why, we can quickly diagnose and correct the code. Then, we can get on with the next task.

第 21 章讨论了测试的静态可读性。本章介绍了一些我们认为有用的做法,以确保测试在运行时为我们提供所需的信息。

Chapter 21 addressed the static readability of tests. This chapter describes some practices that we find helpful to make sure the tests give us the information we need at runtime.

小型、有重点、命名恰当的测试

Small, Focused, Well-Named Tests

改进诊断的最简单方法是保持每个测试小而有针对性,并给予测试可读的名称,如第 21 章所述。如果测试很小,它的名字应该告诉我们关于哪里出了问题的大部分信息。

The easiest way to improve diagnostics is to keep each test small and focused and give tests readable names, as described in Chapter 21. If a test is small, its name should tell us most of what we need to know about what has gone wrong.

解释性断言消息

Explanatory Assertion Messages

JUnit 的断言方法都有一个版本,其中第一个参数是断言失败时显示的消息。从我们看到的情况来看,此功能的使用频率并不高,它本应使断言失败更有用。

JUnit’s assertion methods all have a version in which the first parameter is a message to display when the assertion fails. From what we’ve seen, this feature is not used as often as it should be to make assertion failures more helpful.

例如,当此测试失败时:

For example, when this test fails:

客户客户 = 订单.getCustomer();

assertEquals(“573242”,客户.getAccountId());

assertEquals(16301,客户.getOutstandingBalance());

Customer customer = order.getCustomer();

assertEquals("573242", customer.getAccountId());

assertEquals(16301, customer.getOutstandingBalance());

报告没有明确指出哪些断言是站不住脚的:

the report does not make it obvious which of the assertions has failed:

ComparisonFailure:预期:<[16301]> 但实际为:<[16103]>

ComparisonFailure: expected:<[16301]> but was:<[16103]>

该消息描述的是症状(余额为16103),而不是原因(未结余额计算错误)。

The message is describing the symptom (the balance is 16103) rather than the cause (the outstanding balance calculation is wrong).

如果我们添加一条消息来识别被断言的值:

If we add a message to identify the value being asserted:

assertEquals( "账户 ID" , "573242", customer.getAccountId());

assertEquals( "未结余额" , 16301, customer.getOustandingBalance());

assertEquals("account id", "573242", customer.getAccountId());

assertEquals("outstanding balance", 16301, customer.getOustandingBalance());

我们马上就能看出重点是什么:

we can immediately see what the point is:

ComparisonFailure:预期未结余额:<[16301]> 但实际为:<[16103]>

ComparisonFailure: outstanding balance expected:<[16301]> but was:<[16103]>

使用匹配器突出显示细节

Highlight Detail with Matchers

assertThat()开发人员可以通过使用Hamcrest 匹配器提供另一级别的诊断细节。APIMatcher包括对描述不匹配值的支持,以帮助准确理解不同之处。例如,第 252 页上的工具执行价格断言生成此故障报告:

Developers can provide another level of diagnostic detail by using assertThat() with Hamcrest matchers. The Matcher API includes support for describing the value that mismatched, to help with understanding exactly what is different. For example, the instrument strike price assertion on page 252 generates this failure report:

预期:包含价格大于 <81> 的仪器的集合,

但:价格为 <50>,价格为 <72>,价格为 <31>

Expected: a collection containing instrument at price a value greater than <81>

but: price was <50>, price was <72>, price was <31>

它准确地显示了哪些价值观是相关的。

which shows exactly which values are relevant.

自我描述价值

Self-Describing Value

向断言添加细节的另一种方法是将细节构建到断言的值中。我们可以以与注释暗示代码需要改进的想法相同的精神来理解这一点:如果我们必须向断言添加细节,也许这暗示我们可以让失败更加明显。

An alternative to adding detail to the assertion is to build the detail into values in the assertion. We can take this in the same spirit as the idea that comments are a hint that the code needs to be improved: if we have to add detail to an assertion, maybe that’s a hint that we could make the failure more obvious.

在上面的客户示例中,我们可以通过将测试中的帐户标识符设置Customer为自描述值来改进失败消息"a customer account id"

In the customer example above, we could improve the failure message by setting the account identifier in the test Customer to the self-describing value "a customer account id":

ComparisonFailure:预期为:<[客户帐户 ID ]>,但实际为:<[未设置 ID ]>

ComparisonFailure: expected:<[a customer account id]> but was:<[id not set]>

现在我们不需要添加解释信息,因为值本身已经说明了它的作用。

Now we don’t need to add an explanatory message, because the value itself explains its role.

使用引用类型时,我们可能可以做更多的事情。例如,在具有以下设置的测试中:

We might be able to do more when we’re working with reference types. For example, in a test that has this setup:

日期开始日期 = 新日期(1000);

日期结束日期 = 新日期(2000);

Date startDate = new Date(1000);

Date endDate = new Date(2000);

失败消息报告付款日期错误,但没有说明错误值可能来自何处:

the failure message reports that a payment date is wrong but doesn’t describe where the wrong value might have come from:

java.lang.AssertionError: 付款日期

预期:<Thu Jan 01 01:00:01 GMT 1970>

收到:<Thu Jan 01 01:00:02 GMT 1970>

java.lang.AssertionError: payment date

Expected: <Thu Jan 01 01:00:01 GMT 1970>

got: <Thu Jan 01 01:00:02 GMT 1970>

我们真正想知道的是这些日期的含义。如果我们强制显示字符串:

What we really want to know is the meaning of these dates. If we force the display string:

日期 startDate = namedDate(1000, "startDate");

日期 endDate = namedDate(2000, "endDate");



日期 namedDate(long timeValue, final String name) {

return new Date(timeValue) { public String toString() { return name; } };

}

Date startDate = namedDate(1000, "startDate");

Date endDate = namedDate(2000, "endDate");



Date namedDate(long timeValue, final String name) {

return new Date(timeValue) { public String toString() { return name; } };

}

我们收到一条消息,描述每个日期所起的作用:

we get a message that describes the role that each date plays:

java.lang.AssertionError:付款日期

预期:<startDate>

得到:<endDate>

java.lang.AssertionError: payment date

Expected: <startDate>

got: <endDate>

这清楚表明我们为付款日期分配了错误的字段。1

which makes it clear that we’ve assigned the wrong field to the payment date.1

1.这是定义更多域类型以隐藏语言中基本类型的另一个动机。正如我们在“域类型优于字符串”(第 213页)中讨论的那样,它为我们提供了挂载此类有用行为的地方。

1. This is yet another motivation for defining more domain types to hide the basic types in the language. As we discussed in “Domain Types Are Better Than Strings” (page 213), it gives us somewhere to hang useful behavior like this.

显然罐装价值

Obviously Canned Value

有时,被检查的值无法轻易解释自己。例如,char或中没有足够的信息。一种选择是使用不可能的值,这些值显然与我们在生产中预期的值不同。例如,对于,我们可以使用负值(如果这不会破坏代码)或(如果它远远超出范围)。类似地,上例中的原始版本显然是一个固定值,因为系统中没有任何内容可以追溯到 1970 年。intintInteger.MAX_VALUEstartDate

Sometimes, the values being checked can’t easily explain themselves. There’s not enough information in a char or int, for example. One option is to use improbable values that will be obviously different from the values we would expect in production. For an int, for example, we might use a negative value (if that doesn’t break the code) or Integer.MAX_VALUE (if it’s wildly out of range). Similarly, the original version of startDate in the previous example was an obviously canned value because nothing in the system dated back to 1970.

当团队制定共同价值观的惯例时,它可以确保它们脱颖而出。INVALID_ID上一章末尾的数字是三位数;如果实际系统标识符是五位数及以上,那么这显然是错误的。

When a team develops conventions for common values, it can ensure that they stand out. The INVALID_ID at the end of the last chapter was three digits long; that would be very obviously wrong if real system identifiers were five digits and up.

追踪对象

Tracer Object

有时我们只想检查对象是否由测试代码传递并路由到适当的协作者。我们可以创建一个跟踪器对象(一种显然可以接受的值)来表示该值。跟踪器对象是一个虚拟对象,它本身不支持任何行为,但可以描述在发生故障时它的作用。例如,此测试:

Sometimes we just want to check that an object is passed around by the code under test and routed to the appropriate collaborator. We can create a tracer object, a type of Obviously Canned Value, to represent this value. A tracer object is a dummy object that has no supported behavior of its own, except to describe its role when something fails. For example, this test:

@RunWith(JMock.class)

公共类 CustomerTest {

final LineItem item1 = context.mock(LineItem.class, "item1");

final LineItem item2 = context.mock(LineItem.class, "item2");

final Billing billing = context.mock(Billing.class);



@Test public void

requestInvoiceForPurchasedItems() {

context.checking(new Expectations() {{

oneOf(billing).add(item1);

oneOf(billing).add(item2);

}});



customer.purchase(item1, item2);

customer.requestInvoice(billing);

}

}

@RunWith(JMock.class)

public class CustomerTest {

final LineItem item1 = context.mock(LineItem.class, "item1");

final LineItem item2 = context.mock(LineItem.class, "item2");

final Billing billing = context.mock(Billing.class);



@Test public void

requestsInvoiceForPurchasedItems() {

context.checking(new Expectations() {{

oneOf(billing).add(item1);

oneOf(billing).add(item2);

}});



customer.purchase(item1, item2);

customer.requestInvoice(billing);

}

}

可能会生成如下的失败报告:

might generate a failure report like this:

并非所有期望都得到满足

期望:

预期一次,已调用 1 次:billing.add(<item1>)

!预期一次,从未调用:billing.add(<item2>>)

在此之前发生了什么:

billing.add(<item1>)

not all expectations were satisfied

expectations:

expected once, already invoked 1 time: billing.add(<item1>)

! expected once, never invoked: billing.add(<item2>>)

what happened before this:

billing.add(<item1>)

请注意,jMock 在创建用于故障报告的模拟对象时可以接受名称。事实上,当有多个相同类型的模拟对象时,jMock 会坚持对它们进行命名以避免混淆(默认使用类名)。

Notice that jMock can accept a name when creating a mock object that will be used in failure reporting. In fact, where there’s more than one mock object of the same type, jMock insists that they are named to avoid confusion (the default is to use the class name).

在对类进行 TDD 时,跟踪器对象可能是一种有用的设计工具。我们有时会使用空接口来标记(和命名)领域概念并展示它在协作中的用法。稍后,随着代码的增长,我们会用方法来填充接口以描述其行为。

Tracer objects can be a useful design tool when TDD’ing a class. We sometimes use an empty interface to mark (and name) a domain concept and show how it’s used in a collaboration. Later, as we grow the code, we fill in the interface with methods to describe its behavior.

明确断言期望得到满足

Explicitly Assert That Expectations Were Satisfied

同时包含期望和断言的测试可能会产生令人困惑的失败。在 jMock 和其他模拟对象框架中,期望是在测试主体之后检查的。例如,如果协作无法正常工作并返回错误值,则断言可能会在检查任何期望之前失败。这将产生一个失败报告,其中显示错误的计算结果,而不是实际导致失败的缺少协作。

A test that has both expectations and assertions can produce a confusing failure. In jMock and other mock object frameworks, the expectations are checked after the body of the test. If, for example, a collaboration doesn’t work properly and returns a wrong value, an assertion might fail before any expectations are checked. This would produce a failure report that shows, say, an incorrect calculation result rather than the missing collaboration that actually caused it.

因此,在少数情况下,值得在任何测试断言之前调用该assertIsSatisfied()方法来获取正确的失败报告:Mockery

In a few cases, then, it’s worth calling the assertIsSatisfied() method on the Mockery before any of the test assertions to get the right failure report:

上下文.assertIsSatisfied();

assertThat(result, equalTo(expectedResult));

context.assertIsSatisfied();

assertThat(result, equalTo(expectedResult));

这说明了为什么“观察测试失败” (第 42页)很重要。如果您预期测试会因为未满足期望而失败,但后置条件断言却失败,则您会发现应该添加显式调用来断言所有期望都已得到满足。

This demonstrates why it is important to “Watch the Test Fail” (page 42). If you expect the test to fail because an expectation is not satisfied but a postcondition assertion fails instead, you will see that you should add an explicit call to assert that all expectations have been satisfied.

诊断是一流的功能

Diagnostics Are a First-Class Feature

和其他人一样,我们很容易沉迷于简单的三步 TDD 循环:失败、通过、重构。我们进展顺利,而且我们知道失败意味着什么,因为我们刚刚编写了测试。但现在,我们尝试遵循第 5 章中描述的四步 TDD 循环(失败、报告、通过、重构) ,因为这样我们就知道我们已经理解了该功能——而且一个月后需要更改它的人也会理解它。图 23.1再次表明,我们需要维护测试和生产代码的质量。

Like everyone else, we find it easy to get carried away with the simple three-step TDD cycle: fail, pass, refactor. We’re making good progress and we know what the failures mean because we’ve just written the test. But nowadays, we try to follow the four-step TDD cycle (fail, report, pass, refactor) we described in Chapter 5, because that’s how we know we’ve understood the feature—and whoever has to change it in a month’s time will also understand it. Figure 23.1 shows again that we need to maintain the quality of the tests, as well as the production code.

图 23.1 作为 TDD 周期的一部分改进诊断

Figure 23.1 Improve the diagnostics as part of the TDD cycle

图像

第 24 章 测试灵活性

Chapter 24. Test Flexibility

活的植物柔韧而柔弱,

死的植物脆弱而干燥。

[...]

僵硬的会被折断,

柔软的会被征服。

Living plants are flexible and tender;

the dead are brittle and dry.

[...]

The rigid and stiff will be broken.

The soft and yielding will overcome.

—老子(公元前604—531年)

—Lao Tzu (c.604—531 B.C.)

介绍

Introduction

随着系统及其相关测试套件的增长,如果测试编写不仔细,维护测试可能会成为一种负担。我们已经描述了如何通过使测试易于阅读并在失败时生成有用的诊断来降低测试的持续成本。我们还希望确保每个测试仅在其相关代码出现问题时才会失败。否则,我们最终会得到脆弱的测试,从而减慢开发速度并阻碍重构。测试脆弱性的常见原因包括:

As the system and its associated test suite grows, maintaining the tests can become a burden if they have not been written carefully. We’ve described how we can reduce the ongoing cost of tests by making them easy to read and generating helpful diagnostics on failure. We also want to make sure that each test fails only when its relevant code is broken. Otherwise, we end up with brittle tests that slow down development and inhibit refactoring. Common causes of test brittleness include:

• 测试与系统中不相关的部分或所测试对象的不相关行为耦合过于紧密;

• The tests are too tightly coupled to unrelated parts of the system or unrelated behavior of the object(s) they’re testing;

• 测试过度指定了目标代码的预期行为,对其进行了不必要的限制;

• The tests overspecify the expected behavior of the target code, constraining it more than necessary; and,

• 当多个测试执行相同的生产代码行为时,就会出现重复。

• There is duplication when multiple tests exercise the same production code behavior.

测试脆弱性不仅仅是测试编写方式的一个属性,它还与系统的设计有关。如果一个对象由于依赖关系过多或其依赖关系被隐藏而难以与其环境分离,那么当系统的远程部分发生变化时,其测试就会失败。很难判断修改代码的连锁反应。因此,我们可以将测试脆弱性用作有关设计质量的宝贵反馈来源。

Test brittleness is not just an attribute of how the tests are written; it’s also related to the design of the system. If an object is difficult to decouple from its environment because it has many dependencies or its dependencies are hidden, its tests will fail when distant parts of the system change. It will be hard to judge the knock-on effects of altering the code. So, we can use test brittleness as a valuable source of feedback about design quality.

测试的可读性和弹性之间存在着良性关系。一个重点突出、设置清晰、重复最少的测试更容易命名,其目的也更明显。本章扩展了我们在第 21 章中讨论的一些技术。实际上,整章可以归结为一条规则:

There’s a virtuous relationship with test readability and resilience. A test that is focused, has clean set-up, and has minimal duplication is easier to name and is more obvious about its purpose. This chapter expands on some of the techniques we discussed in Chapter 21. Actually, the whole chapter can be collapsed into a single rule:

明确说明应该发生什么,仅此而已

Specify Precisely What Should Happen and No More

图像

JUnit、Hamcrest 和 jMock 允许我们指定我们想从目标代码中得到什么(其他语言中也有类似的东西)。我们越精确,代码就越能灵活地适应其他不相关的维度,而不会误导测试。我们的经验是,保持测试灵活性的另一个好处是,它们更容易被我们理解,因为它们更清楚地说明了它们在测试什么——关于测试代码中什么是重要的,什么是不重要的。

JUnit, Hamcrest, and jMock allow us to specify just what we want from the target code (there are equivalents in other languages). The more precise we are, the more the code can flex in other unrelated dimensions without breaking tests misleadingly. Our experience is that the other benefit of keeping tests flexible is that they’re easier for us to understand because they are clearer about what they’re testing—about what is and is not important in the tested code.

测试信息,而不是表述

Test for Information, Not Representation

测试可能需要传递一个值来触发它应该在目标对象中执行的行为。该值可以作为参数传递给对象上的方法,也可以作为对象对测试存根的邻居之一进行的查询的结果返回。如果测试的结构是根据系统其他部分如何表示值而构建的,那么它就会依赖于这些部分,并且当这些部分发生变化时就会中断。

A test might need to pass a value to trigger the behavior it’s supposed to exercise in its target object. The value could either be passed in as a parameter to a method on the object, or returned as a result from a query the object makes on one of its neighbors stubbed by the test. If the test is structured in terms of how the value is represented by other parts of the system, then it has a dependency on those parts and will break when they change.

例如,假设我们有一个系统,它使用CustomerBase来存储和查找有关我们客户的信息。它的一个功能是查找给Customer定的电子邮件地址;null如果没有给定地址的客户,它会返回。

For example, imagine we have a system that uses a CustomerBase to store and find information about our customers. One of its features is to look up a Customer given an email address; it returns null if there’s no customer with the given address.

public interface CustomerBase {

// 如果未找到客户,则返回 null

Customer findCustomerWithEmailAddress(String emailAddress);

[...]

}

public interface CustomerBase {

// Returns null if no customer found

Customer findCustomerWithEmailAddress(String emailAddress);

[...]

}

当我们测试通过电子邮件地址搜索客户的代码部分时,我们将存根CustomerBase作为协作对象。在某些测试中,找不到任何客户,因此我们返回null

When we test the parts of the code that search for customers by email address, we stub CustomerBase as a collaborating object. In some of those tests, no customer will be found so we return null:

允许(customerBase)。findCustomerWithEmailAddress(theAddress);

将(returnValue(null));

allowing(customerBase).findCustomerWithEmailAddress(theAddress);

will(returnValue(null));

null在测试中这样使用有两个问题。首先,我们必须记住null这里的意思,以及什么时候合适;测试不是不言自明的。第二个问题是维护成本。

There are two problems with this use of null in a test. First, we have to remember what null means here, and when it’s appropriate; the test is not self-explanatory. The second concern is the cost of maintenance.

一段时间后,我们NullPointerException在生产中遇到了一个问题,并追踪到空引用的来源CustomerBase。我们意识到我们违反了我们的一条设计规则:“永远不要在对象之间传递空值。”羞愧之下,我们改变了CustomerBase的搜索方法以返回一个Maybe类型,该类型实现了最多一个结果的可迭代集合。

Some time later, we experience a NullPointerException in production and track the source of the null reference down to the CustomerBase. We realize we’ve broken one of our design rules: “Never Pass Null between Objects.” Ashamed, we change the CustomerBase’s search methods to return a Maybe type, which implements an iterable collection of at most one result.

公共接口 CustomerBase {

Maybe<Customer> findCustomerWithEmailAddress(String emailAddress);

}



公共抽象类 Maybe<T> 实现 Iterable<T> {

抽象布尔 hasResult();



公共静态 Maybe<T> just(T oneValue) { ...

公共静态 Maybe<T> nothing() { ...

}

public interface CustomerBase {

Maybe<Customer> findCustomerWithEmailAddress(String emailAddress);

}



public abstract class Maybe<T> implements Iterable<T> {

abstract boolean hasResult();



public static Maybe<T> just(T oneValue) { ...

public static Maybe<T> nothing() { ...

}

但是,我们仍然有测试存根CustomerBase返回null,以表示缺失的客户。编译器无法警告我们不匹配,因为也是null类型的有效值Maybe<Customer>,所以我们能做的最好的事情就是观察所有这些测试是否失败,并将每个测试更改为新的设计。

We still, however, have the tests that stub CustomerBase to return null, to represent missing customers. The compiler cannot warn us of the mismatch because null is a valid value of type Maybe<Customer> too, so the best we can do is to watch all these tests fail and change each one to the new design.

相反,如果我们为测试赋予“未找到客户”自己的表示形式,将其作为单个命名良好的常量而不是文字null,我们就可以避免这种繁琐的工作。我们只需更改一行:

If, instead, we’d given the tests their own representation of “no customer found” as a single well-named constant instead of the literal null, we could have avoided this drudgery. We would have changed one line:

公共静态最终客户NO_CUSTOMER_FOUND = null;

public static final Customer NO_CUSTOMER_FOUND = null;

to

公共静态最终 Maybe<Customer> NO_CUSTOMER_FOUND = Maybe.nothing();

public static final Maybe<Customer> NO_CUSTOMER_FOUND = Maybe.nothing();

而无需改变测试本身。

without changing the tests themselves.

测试应该根据对象之间传递的信息来编写,而不是根据信息如何表示来编写。这样做既可以使测试更加一目了然,又可以保护它们不受系统其他地方控制的实现更改的影响。重要的值(如)应该在一个地方定义为常量。第 12 章介绍时NO_CUSTOMER_FOUND还有另一个示例。对于更复杂的结构,我们可以在测试数据构建器中隐藏表示的细节(第 22 章)。UNUSED_CHAT

Tests should be written in terms of the information passed between objects, not of how that information is represented. Doing so will both make the tests more self-explanatory and shield them from changes in implementation controlled elsewhere in the system. Significant values, like NO_CUSTOMER_FOUND, should be defined in one place as a constant. There’s another example in Chapter 12 when we introduce UNUSED_CHAT. For more complex structures, we can hide the details of the representation in test data builders (Chapter 22).

精确断言

Precise Assertions

在测试中,将断言重点放在与测试场景相关的内容上。避免断言不受测试输入驱动的值,并避免重新断言其他测试中涵盖的行为。

In a test, focus the assertions on just what’s relevant to the scenario being tested. Avoid asserting values that aren’t driven by the test inputs, and avoid reasserting behavior that is covered in other tests.

我们发现,这些启发式方法可以指导我们编写测试,其中每个方法都针对目标代码行为的一个独特方面。这使得测试更加稳健,因为它们不依赖于不相关的结果,并且重复性更少。

We find that these heuristics guide us towards writing tests where each method exercises a unique aspect of the target code’s behavior. This makes the tests more robust because they’re not dependent on unrelated results, and there’s less duplication.

大多数测试断言都是简单的相等性检查;例如,我们在“扩展表模型”中断言表模型中的行数。当返回的值变得更大时,相等性测试的扩展性就不好了。复杂。不同的测试场景可能会使测试代码返回仅在特定属性上不同的结果,因此每次比较整个结果会产生误导,并会引入对整个测试对象行为的隐式依赖。

Most test assertions are simple checks for equality; for example, we assert the number of rows in a table model in “Extending the Table Model” (page 180). Testing for equality doesn’t scale well as the value being returned becomes more complex. Different test scenarios may make the tested code return results that differ only in specific attributes, so comparing the entire result each time is misleading and introduces an implicit dependency on the behavior of the whole tested object.

有几种方法可以使结果更复杂。首先,可以将其定义为结构化值类型。这很简单,因为我们可以直接引用我们想要断言的任何属性。例如,如果我们从“使用结构来解释” (第 253页)中获取金融工具,我们可能只需要断言其执行价格:

There are a couple of ways in which a result can be more complex. First, it can be defined as a structured value type. This is straightforward since we can just reference directly any attributes we want to assert. For example, if we take the financial instrument from “Use Structure to Explain” (page 253), we might need to assert only its strike price:

assertEquals("执行价格", 92, Instrument.getStrikePrice());

assertEquals("strike price", 92, instrument.getStrikePrice());

无需比较整个乐器。

without comparing the whole instrument.

我们可以使用 Hamcrest 匹配器使断言更具表现力和更精细。例如,如果我们想断言一个事务标识符大于其前身,我们可以这样写:

We can use Hamcrest matchers to make the assertions more expressive and more finely tuned. For example, if we want to assert that a transaction identifier is larger than its predecessor, we can write:

断言(instrument.getTransactionId(),largerThan(PREVIOUS_TRANSACTION_ID));

assertThat(instrument.getTransactionId(), largerThan(PREVIOUS_TRANSACTION_ID));

这告诉程序员,我们真正关心的只是新标识符是否大于前一个标识符——它的实际值在此测试中并不重要。断言在失败时还会生成一条有用的消息。

This tells the programmer that the only thing we really care about is that the new identifier is larger than the previous one—its actual value is not important in this test. The assertion also generates a helpful message when it fails.

第二个复杂性来源是隐性的,但非常常见。我们经常需要对文本字符串做出断言。有时我们确切地知道文本应该是什么,例如当我们在“扩展虚假拍卖” (第 107页)FakeAuctionServer中查找特定消息时。然而,有时我们只需要检查文本中是否包含某些值。

The second source of complexity is implicit, but very common. We often have to make assertions about a text string. Sometimes we know exactly what the text should be, for example when we have the FakeAuctionServer look for specific messages in “Extending the Fake Auction” (page 107). Sometimes, however, all we need to check is that certain values are included in the text.

一个常见的例子是生成失败消息时。我们不希望所有单元测试都锁定在其当前格式,这样当我们添加空格时它们就会失败,我们也不想采取任何巧妙的措施来处理时间戳。我们只想知道关键信息是否包含在内,因此我们这样写:

A frequent example is when generating a failure message. We don’t want all our unit tests to be locked to its current formatting, so that they fail when we add whitespace, and we don’t want to have to do anything clever to cope with timestamps. We just want to know that the critical information is included, so we write:

assertThat(failureMessage,

allOf(containsString("strikePrice=92"),

containsString("id=FGD.430"),

containsString("已过期")));

assertThat(failureMessage,

allOf(containsString("strikePrice=92"),

containsString("id=FGD.430"),

containsString("is expired")));

它断言所有这些字符串都出现在 中的某个地方failureMessage。这对我们来说已经足够放心了,如果我们认为某条消息很重要,我们可以编写其他测试来检查该消息的格式是否正确。

which asserts that all these strings occur somewhere in failureMessage. That’s enough reassurance for us, and we can write other tests to check that a message is formatted correctly if we think it’s significant.

尝试针对文本字符串编写精确断言的一个有趣效果是,这种努力通常表明我们缺少一个中间结构对象 — 在这种情况下可能是InstrumentFailure。大多数代码将以 的形式编写InstrumentFailure,即包含所有相关字段的结构化值。故障只会在最后一刻转换为字符串,并且可以单独测试该字符串转换。

One interesting effect of trying to write precise assertions against text strings is that the effort often suggests that we’re missing an intermediate structure object—in this case perhaps an InstrumentFailure. Most of the code would be written in terms of an InstrumentFailure, a structured value that carries all the relevant fields. The failure would be converted to a string only at the last possible moment, and that string conversion can be tested in isolation.

精确的期望

Precise Expectations

我们可以将精确断言的概念扩展到精确期望。每个模拟对象测试都应仅指定被测对象与其邻居之间交互的相关细节。对象的组合单元测试描述了其与系统其余部分通信的协议。

We can extend the concept of being precise about assertions to being precise about expectations. Each mock object test should specify just the relevant details of the interactions between the object under test and its neighbors. The combined unit tests for an object describe its protocol for communicating with the rest of the system.

我们在 jMock 中构建了大量支持,以便尽可能精确地指定对象之间的通信。该 API 旨在生成清晰表达对象之间关系的测试,并且由于限制性不强而灵活。这可能需要比其他一些替代方案多一点的测试代码,但我们发现额外的严格性使测试保持清晰。

We’ve built a lot of support into jMock for specifying this communication between objects as precisely as it should be. The API is designed to produce tests that clearly express how objects relate to each other and that are flexible because they’re not too restrictive. This may require a little more test code than some of the alternatives, but we find that the extra rigor keeps the tests clear.

精准参数匹配

Precise Parameter Matching

我们希望传递给方法的值与方法返回的值一样精确。例如,在“断言和期望”中,我们展示了一个期望,其中一个接受的参数是任何类型的;具体类并不重要。同样,在“提取 SnipersTableModelRuntimeException中,我们有这样的期望:

We want to be as precise about the values passed in to a method as we are about the value it returns. For example, in “Assertions and Expectations” (page 254) we showed an expectation where one of the accepted arguments was any type of RuntimeException; the specific class doesn’t matter. Similarly, in “Extracting the SnipersTableModel” (page 197), we have this expectation:

oneOf(拍卖).addAuctionEventListener(带有( sniperForItem (itemId)));

oneOf(auction).addAuctionEventListener(with(sniperForItem(itemId)));

该方法sniperForItem()返回一个Matcher,在给定一个时仅检查项目标识符AuctionSniper。此测试不关心狙击手状态中的任何其他内容,例如其当前出价或最后价格,因此我们不会通过检查这些值使其变得更脆弱。

The method sniperForItem() returns a Matcher that checks only the item identifier when given an AuctionSniper. This test doesn’t care about anything else in the sniper’s state, such as its current bid or last price, so we don’t make it more brittle by checking those values.

相同的精度可以应用于期望输入字符串。例如,如果我们有一个auditTrail对象来接受上面描述的失败消息,我们可以为该审计写一个精确的期望:

The same precision can be applied to expecting input strings. If, for example, we have an auditTrail object to accept the failure message we described above, we can write a precise expectation for that auditing:

oneOf(auditTrail).recordFailure(with(allOf(containsString("strikePrice=92"),

containsString("id=FGD.430"),

containsString("已过期"))));

oneOf(auditTrail).recordFailure(with(allOf(containsString("strikePrice=92"),

containsString("id=FGD.430"),

containsString("is expired"))));

允许和期望

Allowances and Expectations

我们在“狙击手获取某种状态”(第 144页)中引入了允许的概念。jMock 坚持在测试期间满足所有期望,但允许可能匹配也可能不匹配。区别在于强调特定测试中的重要事项。期望描述了我们正在测试的协议所必需的交互:如果我们将此消息发送对象,我们希望看到它将另一条消息发送邻居

We introduced the concept of allowances in “The Sniper Acquires Some State” (page 144). jMock insists that all expectations are met during a test, but allowances may be matched or not. The point of the distinction is to highlight what matters in a particular test. Expectations describe the interactions that are essential to the protocol we’re testing: if we send this message to the object, we expect to see it send this other message to this neighbor.

允许支持我们正在测试的交互。我们经常将它们用作存根,将值输入到对象中,以使对象进入我们要测试的行为的正确状态。我们还使用它们来忽略不相关的其他交互到当前测试。例如,在“Repurposing sniperBidding()”中,我们有一个测试,其中包括:

Allowances support the interaction we’re testing. We often use them as stubs to feed values into the object, to get the object into the right state for the behavior we want to test. We also use them to ignore other interactions that aren’t relevant to the current test. For example, in “Repurposing sniperBidding()” we have a test that includes:

忽略(拍卖);

允许(sniperListener)。sniperStateChanged(带有(aSniperThatIs(BIDDING)));

然后(sniperState.is(“竞标”));

ignoring(auction);

allowing(sniperListener).sniperStateChanged(with(aSniperThatIs(BIDDING)));

then(sniperState.is("bidding"));

ignoring()子句表示,在此测试中,我们不关心发送到 的消息auction;它们将在其他测试中介绍。该allowing()子句将任何 的调用sniperStateChanged()与当前正在竞标的狙击手匹配,但不坚持这样的调用发生。在此测试中,我们使用 allowance 来记录狙击手告诉我们的有关其状态的信息。该方法aSniperThatIs()返回一个仅在给定 时Matcher检查 的。SniperStateSniperSnapshot

The ignoring() clause says that, in this test, we don’t care about messages sent to the auction; they will be covered in other tests. The allowing() clause matches any call to sniperStateChanged() with a Sniper that is currently bidding, but doesn’t insist that such a call happens. In this test, we use the allowance to record what the Sniper has told us about its state. The method aSniperThatIs() returns a Matcher that checks only the SniperState when given a SniperSnapshot.

在其他测试中,我们将“操作”子句附加到许可中,以便调用将返回一个值或引发异常。例如,我们可能有一个许可,它将存根catalog以返回一个price将在测试稍后使用的:

In other tests we attach “action” clauses to allowances, so that the call will return a value or throw an exception. For example, we might have an allowance that stubs the catalog to return a price that will be returned for use later in the test:

允许(目录)。getPriceForItem(item);将(returnValue(74));

allowing(catalog).getPriceForItem(item); will(returnValue(74));

允许和期望之间的区别并不严格,但我们发现这个简单的规则很有帮助:

The distinction between allowances and expectations isn’t rigid, but we’ve found that this simple rule helps:

允许查询;期待命令

Allow Queries; Expect Commands

图像

命令是可能会产生副作用的调用,用于改变目标对象之外的世界。当我们告诉auditTrail上述方法记录失败时,我们期望这会改变某种日志的内容。如果我们调用该方法的次数不同,系统的状态就会不同。

Commands are calls that are likely to have side effects, to change the world outside the target object. When we tell the auditTrail above to record a failure, we expect that to change the contents of some kind of log. The state of the system will be different if we call the method a different number of times.

查询catalog不会改变世界,因此可以调用任意次,甚至不调用。在上面的例子中,我们询问价格的次数对系统没有任何影响。

Queries don’t change the world, so they can be called any number of times, including none. In our example above, it doesn’t make any difference to the system how many times we ask the catalog for a price.

该规则有助于将测试与测试对象分离开来。如果实现发生变化,例如引入缓存或使用不同的算法,测试仍然有效。另一方面,如果我们为缓存编写测试,我们会希望确切知道查询的频率。

The rule helps to decouple the test from the tested object. If the implementation changes, for example to introduce caching or use a different algorithm, the test is still valid. On the other hand, if we were writing a test for a cache, we would want to know exactly how often the query was made.

jMock 支持对调用频率进行更多种类的检查,而不仅仅是allowing()oneOf()。预期调用的次数由启动期望的“基数”子句定义。在“拍卖狙击手出价”中,我们看到了以下示例:

jMock supports more varied checking of how often a call is made than just allowing() and oneOf(). The number of times a call is expected is defined by the “cardinality” clause that starts the expectation. In “The AuctionSniper Bids,” we saw the example:

至少(1) .of(sniperListener).sniperBidding();

atLeast(1).of(sniperListener).sniperBidding();

这表明我们关心的是调用是否进行,而不是调用了多少次。还有其他子句允许对预期调用次数进行微调,如附录 A中所示。

which says that we care that this call is made, but not how many times. There are other clauses which allow fine-tuning of the number of times a call is expected, listed in Appendix A.

忽略不相关的对象

Ignoring Irrelevant Objects

正如您所见,我们可以通过“忽略”与所执行功能无关的协作者来简化测试。jMock 将不会检查对被忽略对象的任何调用。这使测试保持简单和专注,因此我们可以立即看到什么是重要的,并且对代码某一方面的更改不会破坏不相关的测试。

As you’ve seen, we can simplify a test by “ignoring” collaborators that are not relevant to the functionality being exercised. jMock will not check any calls to ignored objects. This keeps the test simple and focused, so we can immediately see what’s important and changes to one aspect of the code do not break unrelated tests.

为了方便起见,jMock 将根据返回类型为返回值的被忽略的方法提供“零”结果:

As a convenience, jMock will provide “zero” results for ignored methods that return a value, depending on the return type:

图像

动态模拟返回类型的能力可以成为缩小测试范围的强大工具。例如,对于使用 Java 持久性 API (JPA) 的代码,测试可以忽略EntityManagerFactory。工厂将返回一个被忽略的EntityManager,它将返回一个被忽略的,EntityTransaction我们可以忽略commit()rollback()。使用一个 ignore 子句,测试可以通过禁用与​​事务相关的所有内容来专注于代码的域行为。

The ability to dynamically mock returned types can be a powerful tool for narrowing the scope of a test. For example, for code that uses the Java Persistence API (JPA), a test can ignore the EntityManagerFactory. The factory will return an ignored EntityManager, which will return an ignored EntityTransaction on which we can ignore commit() or rollback(). With one ignore clause, the test can focus on the code’s domain behavior by disabling everything to do with transactions.

像所有“强力工具”一样,ignoring()应谨慎使用。忽略的对象链可能表明应该将功能拉到新的协作者中。作为程序员,我们还必须确保忽略的功能在某处进行测试,并且有更高级别的测试来确保所有内容协同工作。在实践中,我们通常ignoring()只在基础到位后编写专门的测试时引入,例如在“狙击手获得某种状态”(第 144页)中。

Like all “power tools,” ignoring() should be used with care. A chain of ignored objects might suggest that the functionality ought to be pulled out into a new collaborator. As programmers, we must also make sure that ignored features are tested somewhere, and that there are higher-level tests to make sure everything works together. In practice, we usually introduce ignoring() only when writing specialized tests after the basics are in place, as for example in “The Sniper Acquires Some State” (page 144).

调用顺序

Invocation Order

jMock 允许以任何顺序调用模拟对象;期望不必按照相同的顺序声明。1在测试中,我们对交互顺序的描述越少,我们在代码实现方面的灵活性就越高。我们还可以在测试结构方面获得灵活性;例如,我们可以通过将期望打包到辅助方法中来提高测试方法的可读性。

jMock allows invocations on a mock object to be called in any order; the expectations don’t have to be declared in the same sequence.1 The less we say in the tests about the order of interactions, the more flexibility we have with the implementation of the code. We also gain flexibility in how we structure the tests; for example, we can make test methods more readable by packaging up expectations in helper methods.

1.一些早期的模拟框架严格遵循“记录/回放”原则:实际调用必须与预期调用的顺序相匹配。虽然现在没有框架强制执行此要求,但这种误解仍然很普遍。

1. Some early mock frameworks were strictly “record/playback”: the actual calls had to match the sequence of the expected calls. No frameworks enforce this any more, but the misconception is still common.

仅在重要时才强制执行调用顺序

Only Enforce Invocation Order When It Matters

图像

有时调用的顺序很重要,在这种情况下,我们会在测试中添加显式约束。将此类约束保持在最低限度可避免锁定生产代码。它还有助于我们了解每种情况是否有必要——有序约束非常罕见,以至于每种用途都引人注目。

Sometimes the order in which calls are made is significant, in which case we add explicit constraints to the test. Keeping such constraints to a minimum avoids locking down the production code. It also helps us see whether each case is necessary—ordered constraints are so uncommon that each use stands out.

jMock 有两种机制来限制调用顺序:序列(定义有序的调用列表)和状态机(可以描述更复杂的顺序约束)。序列比状态机更容易理解,但如果使用不当,它们的限制性可能会使测试变得脆弱。

jMock has two mechanisms for constraining invocation order: sequences, which define an ordered list of invocations, and state machines, which can describe more sophisticated ordering constraints. Sequences are simpler to understand than state machines, but their restrictiveness can make tests brittle if used inappropriately.

序列对于确认对象是否按正确顺序向其邻居发送通知最为有用。例如,我们需要一个AuctionSearcher对象来搜索其Auctions 集合,以查找哪些 s 与给定关键字集合中的任何内容相匹配。每当它找到匹配项时,搜索者就会AuctionSearchListener通过调用searchMatched()匹配拍卖来通知其。搜索者将通过调用 来告诉侦听器它已经尝试了所有可用的拍卖searchFinished()

Sequences are most useful for confirming that an object sends notifications to its neighbors in the right order. For example, we need an AuctionSearcher object that will search its collection of Auctions to find which ones match anything from a given set of keywords. Whenever it finds a match, the searcher will notify its AuctionSearchListener by calling searchMatched() with the matching auction. The searcher will tell the listener that it’s tried all of its available auctions by calling searchFinished().

我们的第一次测试尝试如下:

Our first attempt at a test looks like this:

公共类 AuctionSearcherTest { [...]

@Test public void

publishesMatchForOneAuction() {

final AuctionSearcher auctionSearch =

new AuctionSearcher(searchListener, asList(STUB_AUCTION1));

context.checking(new Expectations() {{

oneOf(searchListener).searchMatched(STUB_AUCTION1);

oneOf(searchListener).searchFinished();

}});

auctionSearch.searchFor(KEYWORDS);

}

}

public class AuctionSearcherTest { [...]

@Test public void

announcesMatchForOneAuction() {

final AuctionSearcher auctionSearch =

new AuctionSearcher(searchListener, asList(STUB_AUCTION1));

context.checking(new Expectations() {{

oneOf(searchListener).searchMatched(STUB_AUCTION1);

oneOf(searchListener).searchFinished();

}});

auctionSearch.searchFor(KEYWORDS);

}

}

其中searchListener是一个模拟AuctionSearchListenerKEYWORDS是一组关键字字符串,而STUB_AUCTION1是一个存根实现,Auction它将匹配 中的一个字符串KEYWORDS

where searchListener is a mock AuctionSearchListener, KEYWORDS is a set of keyword strings, and STUB_AUCTION1 is a stub implementation of Auction that will match one of the strings in KEYWORDS.

这个测试的问题是,在 之前没有任何东西可以阻止searchFinished()调用searchMatched(),这没有意义。我们有一个 接口AuctionSearchListener,但我们还没有描述它的协议。我们可以通过添加 来描述对侦听器的调用之间的关系来解决这个问题。如果先被调用,Sequence测试将失败。searchFinished()

The problem with this test is that there’s nothing to stop searchFinished() being called before searchMatched(), which doesn’t make sense. We have an interface for AuctionSearchListener, but we haven’t described its protocol. We can fix this by adding a Sequence to describe the relationship between the calls to the listener. The test will fail if searchFinished() is called first.

@Test public void

publishesMatchForOneAuction() {

final AuctionSearcher auctionSearch =

new AuctionSearcher(searchListener, asList(STUB_AUCTION1));



context.checking(new Expectations() {{

Sequence events = context.sequence("events");



oneOf(searchListener).searchMatched(STUB_AUCTION1); inSequence(events);

oneOf(searchListener).searchFinished(); inSequence(events);

}});



auctionSearch.searchFor(KEYWORDS);

}

@Test public void

announcesMatchForOneAuction() {

final AuctionSearcher auctionSearch =

new AuctionSearcher(searchListener, asList(STUB_AUCTION1));



context.checking(new Expectations() {{

Sequence events = context.sequence("events");



oneOf(searchListener).searchMatched(STUB_AUCTION1); inSequence(events);

oneOf(searchListener).searchFinished(); inSequence(events);

}});



auctionSearch.searchFor(KEYWORDS);

}

我们将继续使用此序列,并添加更多匹配的拍卖:

We continue using this sequence as we add more auctions to match:

@Test public void

publishMatchForTwoAuctions() {

final AuctionSearcher auctionSearch = new AuctionSearcher(searchListener,

new AuctionSearcher(searchListener,

asList(STUB_AUCTION1, STUB_AUCTION2 ));



context.checking(new Expectations() {{

Sequence events = context.sequence("events");



oneOf(searchListener).searchMatched(STUB_AUCTION1); inSequence(events);

oneOf(searchListener).searchMatched(STUB_AUCTION2); inSequence(events);

oneOf(searchListener).searchFinished(); inSequence(events);

}});



auctionSearch.searchFor(KEYWORDS);

}

@Test public void

announcesMatchForTwoAuctions() {

final AuctionSearcher auctionSearch = new AuctionSearcher(searchListener,

new AuctionSearcher(searchListener,

asList(STUB_AUCTION1, STUB_AUCTION2));



context.checking(new Expectations() {{

Sequence events = context.sequence("events");



oneOf(searchListener).searchMatched(STUB_AUCTION1); inSequence(events);

oneOf(searchListener).searchMatched(STUB_AUCTION2); inSequence(events);

oneOf(searchListener).searchFinished(); inSequence(events);

}});



auctionSearch.searchFor(KEYWORDS);

}

但这是否对协议的限制过多?我们是否必须按照拍卖初始化时的顺序来匹配拍卖?也许我们关心的只是在搜索结束之前进行正确的匹配。我们可以用对象来放松排序约束(我们第一次看到它是在第144States页的“狙击手获得某种状态”中)。

But is this overconstraining the protocol? Do we have to match auctions in the same order that they’re initialized? Perhaps all we care about is that the right matches are made before the search is closed. We can relax the ordering constraint with a States object (which we first saw in “The Sniper Acquires Some State” on page 144).

AStates实现具有命名状态的抽象状态机。我们可以通过将then()子句附加到期望来触发状态转换。我们可以强制执行仅当对象处于(或不处于)特定状态时才使用when()子句进行调用。我们重写测试:

A States implements an abstract state machine with named states. We can trigger state transitions by attaching a then() clause to an expectation. We can enforce that an invocation only happens when object is (or is not) in a particular state with a when() clause. We rewrite our test:

@Test public void

publishMatchForTwoAuctions() {

final AuctionSearcher auctionSearch = new AuctionSearcher(searchListener,

new AuctionSearcher(searchListener,

asList(STUB_AUCTION1, STUB_AUCTION2));



context.checking(new Expectations() {{

States searching = context.states("searching");



oneOf(searchListener).searchMatched(STUB_AUCTION1);

when(searching.isNot("finished"));

oneOf(searchListener).searchMatched(STUB_AUCTION2);

when(searching.isNot("finished"));

oneOf(searchListener).searchFinished(); then(searching.is("finished"));

}});



auctionSearch.searchFor(KEYWORDS);

}

@Test public void

announcesMatchForTwoAuctions() {

final AuctionSearcher auctionSearch = new AuctionSearcher(searchListener,

new AuctionSearcher(searchListener,

asList(STUB_AUCTION1, STUB_AUCTION2));



context.checking(new Expectations() {{

States searching = context.states("searching");



oneOf(searchListener).searchMatched(STUB_AUCTION1);

when(searching.isNot("finished"));

oneOf(searchListener).searchMatched(STUB_AUCTION2);

when(searching.isNot("finished"));

oneOf(searchListener).searchFinished(); then(searching.is("finished"));

}});



auctionSearch.searchFor(KEYWORDS);

}

当测试打开时,searching处于未定义(默认)状态。只要searching尚未完成,搜索者就可以报告匹配项。当搜索者报告已完成时,then()子句将切换searchingfinished,从而阻止任何进一步的匹配项。

When the test opens, searching is in an undefined (default) state. The searcher can report matches as long as searching is not finished. When the searcher reports that it has finished, the then() clause switches searching to finished, which blocks any further matches.

状态和序列可以组合使用。例如,如果我们的需求发生变化,拍卖必须按顺序进行匹配,那么除了现有searching状态之外,我们还可以添加一个仅用于匹配的序列。新序列将确认搜索结果的顺序,现有状态将确认结果在搜索完成之前到达。如果协议需要,期望可以属于多个状态和序列。我们很少需要这种复杂性——最常见的情况是响应我们不拥有协议的外部事件反馈——我们总是将其视为应该将某些东西分解成更小、更简单的部分的暗示。

States and sequences can be used in combination. For example, if our requirements change so that auctions have to be matched in order, we can add a sequence for just the matches, in addition to the existing searching states. The new sequence would confirm the order of search results and the existing states would confirm that the results arrived before the search is finished. An expectation can belong to multiple states and sequences, if that’s what the protocol requires. We rarely need such complexity—it’s most common when responding to external feeds of events where we don’t own the protocol—and we always take it as a hint that something should be broken up into smaller, simpler pieces.

当期望顺序很重要时

When Expectation Order Matters

图像

实际上,jMock 期望声明的顺序有时很重要,但这并不是因为它们必须影响调用顺序。期望被附加到一个列表中,通过按顺序搜索此列表来匹配调用。如果有两个期望可以匹配调用,则第一个声明的期望将获胜。如果第一个期望实际上是允许的,则第二个期望将永远不会匹配,并且测试将失败。

Actually, the order in which jMock expectations are declared is sometimes significant, but not because they have to shadow the order of invocation. Expectations are appended to a list, and invocations are matched by searching this list in order. If there are two expectations that can match an invocation, the one declared first will win. If that first expectation is actually an allowance, the second expectation will never see a match and the test will fail.

jMock 状态的强大功能

The Power of jMock States

jMockStates已被证明是一种有用的构造。我们可以使用它来对测试中的三种参与者进行建模:被测试的对象、其同类对象以及测试本身。

jMock States has turned out to be a useful construct. We can use it to model each of the three types of participants in a test: the object being tested, its peers, and the test itself.

我们可以表示对被测试对象状态的理解,如上例所示。测试监听对象发送给其对等方的事件,并利用这些事件触发状态转换,并拒绝破坏对象协议的事件。

We can represent our understanding of the state of the object being tested, as in the example above. The test listens for the events the object sends out to its peers and uses them to trigger state transitions and to reject events that would break the object’s protocol.

正如我们在“表示对象状态”(第 146页)中所写,这是测试对象状态的逻辑States表示。A描述测试发现的与对象相关的内容,而不是其内部结构。我们不想限制对象的实现。

As we wrote in “Representing Object State” (page 146), this is a logical representation of the state of the tested object. A States describes what the test finds relevant about the object, not its internal structure. We don’t want to constrain the object’s implementation.

我们可以表示对等体在被测试对象调用时如何改变状态。例如,在上面的例子中,我们可能希望坚持侦听器在接收任何结果之前必须准备就绪,因此搜索器必须查询其状态。我们可以添加一个新的States, listenerState

We can represent how a peer changes state as it’s called by the tested object. For instance, in the example above, we might want to insist that the listener must be ready before it can receive any results, so the searcher must query its state. We could add a new States, listenerState:

允许(searchListener)。isReady();将(returnValue(true));

然后(listenerState。是(“就绪”));

oneOf(searchListener)。searchMatched(STUB_AUCTION1);

当(listenerState。是(“就绪”));

allowing(searchListener).isReady(); will(returnValue(true));

then(listenerState.is("ready"));

oneOf(searchListener).searchMatched(STUB_AUCTION1);

when(listenerState.is("ready"));

最后,我们可以表示测试本身的状态。例如,我们可以强制在设置测试时忽略某些交互:

Finally, we can represent the state of the test itself. For example, we could enforce that some interactions are ignored while the test is being set up:

忽略(拍卖);当(testState.isNot(“正在运行”))时;

testState.成为(“正在运行”);

其中之一(拍卖)。bidMore();当(testState.is(“正在运行”))时;

ignoring(auction); when(testState.isNot("running"));

testState.become("running");

oneOf(auction).bidMore(); when(testState.is("running"));

更加自由的期望

Even More Liberal Expectations

最后,jMock 具有插件点来支持任意期望的定义。例如,我们可以编写一个期望来接受任何 getter 方法:

Finally, jMock has plug-in points to support the definition of arbitrary expectations. For example, we could write an expectation to accept any getter method:

允许(aPeerObject).方法(startsWith(“get”)).withNoArguments();

allowing(aPeerObject).method(startsWith("get")).withNoArguments();

或者接受对一组对象之一的调用:

or to accept a call to one of a set of objects:

其中之一(任意(相同(o1),相同(o2),相同(o3)))。方法(“doSomething”);

oneOf (anyOf(same(o1),same(o2),same(o3))).method("doSomething");

这种期望将我们从静态类型世界带入了动态类型世界,这既带来了强大功能,也带来了风险。这些是我们最强大的“强大工具”功能——有时正是我们需要的,但始终要小心使用。jMock 文档中有更多详细信息。

Such expectations move us from a statically typed to a dynamically typed world, which brings both power and risk. These are our strongest “power tool” features—sometimes just what we need but always to be used with care. There’s more detail in the jMock documentation.

“豚鼠”物品

“Guinea Pig” Objects

在我们在“可维护性设计”(第47页)中描述的“端口和适配器”架构中,适配器将应用程序域对象映射到系统的技术基础架构上。我们看到的大多数适配器实现都是通用的;例如,它们通常使用反射在域之间移动值。我们可以将此类映射应用于任何类型的对象,这意味着我们可以在不触及映射代码的情况下更改域模型。

In the “ports and adapters” architecture we described in “Designing for Maintainability” (page 47), the adapters map application domain objects onto the system’s technical infrastructure. Most of the adapter implementations we see are generic; for example, they often use reflection to move values between domains. We can apply such mappings to any type of object, which means we can change our domain model without touching the mapping code.

为适配器代码编写测试时,最简单的方法是使用应用程序域模型中的类型,但这会使测试变得脆弱,因为它将应用程序和适配器域绑定在一起。当我们更改应用程序模型时,它会带来误导性破坏测试的风险,因为我们没有分离关注点。

The easiest approach when writing tests for the adapter code is to use types from the application domain model, but this makes the test brittle because it binds together the application and adapter domains. It introduces a risk of misleadingly breaking tests when we change the application model, because we haven’t separated the concerns.

这是一个例子。系统使用XmlMarshaller将对象编组为 XML 或从 XML 编组为 XML,以便它们可以通过网络发送。此测试XmlMarshaller通过往返AuctionClosedEvent对象进行练习:生产系统确实通过网络发送的一种类型。

Here’s an example. A system uses an XmlMarshaller to marshal objects to and from XML so they can be sent across a network. This test exercises XmlMarshaller by round-tripping an AuctionClosedEvent object: a type that the production system really does send across the network.

公共类 XmlMarshallerTest {

@Test 公共 void

marshallsAndUnmarshallsSerialisableFields() {

XMLMarshaller marshaller = new XmlMarshaller();



AuctionClosedEvent original = new AuctionClosedEventBuilder().build();



字符串 xml = marshaller.marshall(original);

AuctionClosedEvent unmarshalled = marshaller.unmarshall(xml);



assertThat(unmarshalled, hasSameSerialisableFieldsAs(original));

}

}

public class XmlMarshallerTest {

@Test public void

marshallsAndUnmarshallsSerialisableFields() {

XMLMarshaller marshaller = new XmlMarshaller();



AuctionClosedEvent original = new AuctionClosedEventBuilder().build();



String xml = marshaller.marshall(original);

AuctionClosedEvent unmarshalled = marshaller.unmarshall(xml);



assertThat(unmarshalled, hasSameSerialisableFieldsAs(original));

}

}

后来我们决定我们的系统毕竟不会发送AuctionClosedEvent,所以我们应该能够删除该类。我们的重构尝试将失败,因为AuctionClosedEvent仍在被 所使用XmlMarshallerTest。不相关的耦合将迫使我们不必要地重新进行测试。

Later we decide that our system won’t send an AuctionClosedEvent after all, so we should be able to delete the class. Our refactoring attempt will fail because AuctionClosedEvent is still being used by the XmlMarshallerTest. The irrelevant coupling will force us to rework the test unnecessarily.

当我们将测试与领域类型耦合时,会出现一个更重要(且更微妙)的问题:更难发现测试假设何时被打破。例如,我们XmlMarshallerTest还检查编组器如何处理瞬态和非瞬态字段。当我们编写测试时,AuctionClosedEvent包括两种类型的字段,因此我们通过编组器执行所有路径。后来,我们从中删除了瞬态字段AuctionClosedEvent,这意味着我们有一些不再有意义但不会失败的测试。没有任何迹象表明我们的测试已停止工作并且重要功能未被覆盖。

There’s a more significant (and subtle) problem when we couple tests to domain types: it’s harder to see when test assumptions have been broken. For example, our XmlMarshallerTest also checks how the marshaller handles transient and non-transient fields. When we wrote the tests, AuctionClosedEvent included both kind of fields, so we were exercising all the paths through the marshaller. Later, we removed the transient fields from AuctionClosedEvent, which means that we have tests that are no longer meaningful but do not fail. Nothing is alerting us that we have tests that have stopped working and that important features are not being covered.

我们应该XmlMarshaller使用明确其所代表的功能的特定类型进行测试,这些类型与实际系统无关。例如,我们可以在测试中引入辅助类:

We should test the XmlMarshaller with specific types that are clear about the features that they represent, unrelated to the real system. For example, we can introduce helper classes in the test:

public class XmlMarshallerTest {

public static class MarshalledObject {

private String privateField = "private";

public final String publicFinalField = "public final";

public int primitiveField;

// 构造函数、私有字段的访问器等。

}

public static class WithTransient extends MarshalledObject {

public temporary String temporaryField = "transient";

}



@Test public void

marshallsAndUnmarshallsSerialisableFields() {

XMLMarshaller marshaller = new XmlMarshaller();



WithTransient original = new WithTransient();



String xml = marshaller.marshall(original);

AuctionClosedEvent unmarshalled = marshaller.unmarshall(xml);



assertThat(unmarshalled, hasSameSerialisableFieldsAs(original));

}

}

public class XmlMarshallerTest {

public static class MarshalledObject {

private String privateField = "private";

public final String publicFinalField = "public final";

public int primitiveField;

// constructors, accessors for private field, etc.

}

public static class WithTransient extends MarshalledObject {

public transient String transientField = "transient";

}



@Test public void

marshallsAndUnmarshallsSerialisableFields() {

XMLMarshaller marshaller = new XmlMarshaller();



WithTransient original = new WithTransient();



String xml = marshaller.marshall(original);

AuctionClosedEvent unmarshalled = marshaller.unmarshall(xml);



assertThat(unmarshalled, hasSameSerialisableFieldsAs(original));

}

}

该类WithTransient充当着“豚鼠”的角色,使我们能够XmlMarshaller在将其应用到生产领域模型之前详尽地练习它的行为。它还WithTransient使我们的测试更具可读性,因为该类及其字段是“自描述​​值”的示例,其名称反映了它们在测试中的角色。

The WithTransient class acts as a “guinea pig,” allowing us to exhaustively exercise the behavior of our XmlMarshaller before we let it loose on our production domain model. WithTransient also makes our test more readable because the class and its fields are examples of “Self-Describing Value” (page 269), with names that reflect their roles in the test.

第五部分 高级主题

Part V. Advanced Topics

在本部分中,我们将介绍一些经常导致团队在测试驱动开发中遇到困难的主题。这些主题的共同点是它们跨越了功能级和系统级设计的界限。例如,当我们查看多线程代码时,我们需要测试线程内运行的行为以及不同线程交互的方式。

In this part, we cover some topics that regularly cause teams to struggle with test-driven development. What’s common to these topics is that they cross the boundary between feature-level and system-level design. For example, when we look at multi-threaded code, we need to test both the behavior that runs within a thread and the way different threads interact.

我们的经验是,如果我们不清楚要解决的是哪个方面,这样的代码很难测试。把所有东西都放在一起会产生令人困惑、脆弱且有时具有误导性的测试。当我们花时间倾听这些“测试异味”时,它们通常会引导我们设计出更好的设计,并更清晰地划分职责。

Our experience is that such code is difficult to test when we’re not clear about which aspect we’re addressing. Lumping everything together produces tests that are confusing, brittle, and sometimes misleading. When we take the time to listen to these “test smells,” they often lead us to a better design with a clearer separation of responsibilities.

第 25 章 测试持久性

Chapter 25. Testing Persistence

我们总是在某一转瞬即逝的心理状态中制定出持久的决心。

It is always during a passing state of mind that we make lasting resolutions.

—马塞尔·普鲁斯特

—Marcel Proust

介绍

Introduction

正如我们在第 8 章中看到的,当我们根据第三方 API 定义抽象时,我们必须测试我们的抽象在与该 API 集成时是否按预期运行,但不能使用我们的测试来获取有关其设计的反馈。

As we saw in Chapter 8, when we define an abstraction in terms of a third-party API, we have to test that our abstraction behaves as we expect when integrated with that API, but cannot use our tests to get feedback about its design.

一个常见的例子是使用持久性机制实现的抽象,例如对象/关系映射 (ORM)。ORM 在简单的 API 背后隐藏了许多复杂的功能。当我们在 ORM 上构建抽象时,我们需要测试我们的实现是否发送了正确的查询、是否正确配置了对象与关系模式之间的映射、是否使用与数据库兼容的 SQL 方言、是否执行与数据库完整性约束兼容的更新和删除、是否与事务管理器正确交互、是否及时释放外部资源、是否不会因数据库驱动程序中的任何错误而出错等等。

A common example is an abstraction implemented using a persistence mechanism, such as Object/Relational Mapping (ORM). ORM hides a lot of sophisticated functionality behind a simple API. When we build an abstraction upon an ORM, we need to test that our implementation sends correct queries, has correctly configured the mappings between our objects and the relational schema, uses a dialect of SQL that is compatible with the database, performs updates and deletes that are compatible with the integrity constraints of the database, interacts correctly with the transaction manager, releases external resources in a timely manner, does not trip over any bugs in the database driver, and much more.

在测试持久性代码时,我们还需要更加担心测试的质量。有些组件在后台运行,测试必须正确设置这些组件。这些组件具有持久状态,可能会导致测试相互干扰。我们的测试代码必须处理所有这些额外的复杂性。我们需要付出额外的努力来确保我们的测试保持可读性,并生成合理的诊断来查明测试失败的原因——告诉我们失败发生在哪个组件以及原因。

When testing persistence code, we also have more to worry about with respect to the quality of our tests. There are components running in the background that the test must set up correctly. Those components have persistent state that could make tests interfere with each other. Our test code has to deal with all this extra complexity. We need to spend additional effort to ensure that our tests remain readable and to generate reasonable diagnostics that pinpoint why tests fail—to tell us in which component the failure occurred and why.

本章介绍了一些处理这种复杂性的技术。示例代码使用标准 Java 持久性 API (JPA),但这些技术与其他持久性机制(如 Java 数据对象 (JDO)、开源 ORM 技术(如 Hibernate))配合使用效果也很好,甚至在使用数据映射机制(如 XStream)将对象转储到文件时也同样适用1或标准 Java API for XML Binding (JAXB)。2

This chapter describes some techniques for dealing with this complexity. The example code uses the standard Java Persistence API (JPA), but the techniques will work just as well with other persistence mechanisms, such as Java Data Objects (JDO), open source ORM technologies like Hibernate, or even when dumping objects to files using a data-mapping mechanism such as XStream1 or the standard Java API for XML Binding (JAXB).2

1 . http://xstream.codehaus.org

1. http://xstream.codehaus.org

2.对所有缩写表示歉意。Java 标准化过程并不要求标准具有令人难忘的名称。

2. Apologies for all the acronyms. The Java standardization process does not require standards to have memorable names.

示例场景

An Example Scenario

本章中的示例都将使用相同的场景。我们现在有一个代表客户执行拍卖狙击的 Web 服务。

The examples in this chapter will all use the same scenario. We now have a web service that performs auction sniping on behalf of our customers.

客户可以登录不同的拍卖网站,并拥有一种或多种付款方式,用于支付我们的服务和竞拍的拍品。系统支持两种付款方式:信用卡和在线支付服务 PayMate。客户有一个联系地址,如果他们有信用卡,信用卡上还有一个账单地址。

A customer can log in to different auction sites and has one or more payment methods by which they pay for our service and the lots they bid for. The system supports two payment methods: credit cards and an online payment service called PayMate. A customer has a contact address and, if they have a credit card, the card has a billing address.

该领域模型在我们的系统中由图 25.1所示的持久实体表示(仅包括显示实体用途的字段。)

This domain model is represented in our system by the persistent entities shown in Figure 25.1 (which only includes the fields that show what the purpose of the entity is.)

图25.1 持久实体

Figure 25.1 Persistent entities

图像

隔离影响持久状态的测试

Isolate Tests That Affect Persistent State

由于持久性数据会从一个测试转移到下一个测试,因此我们必须格外小心,确保持久性测试彼此隔离。JUnit 无法为我们做到这一点,因此测试装置必须确保测试在已知状态下从其持久性资源开始。

Since persistent data hangs around from one test to the next, we have to take extra care to ensure that persistence tests are isolated from one another. JUnit cannot do this for us, so the test fixture must ensure that the test starts with its persistent resources in a known state.

对于数据库代码,这意味着在测试开始之前从数据库表中删除行。清理数据库的过程取决于数据库的完整性约束。可能只能按照严格的顺序清除表。此外,如果某些表之间存在级联删除的外键约束,则清理一个表将自动清理其他表。

For database code, this means deleting rows from the database tables before the test starts. The process of cleaning the database depends on the database’s integrity constraints. It might only be possible to clear tables in a strict order. Furthermore, if some tables have foreign key constraints between them that cascade deletes, cleaning one table will automatically clean others.

在测试开始时清理持久数据,而不是在测试结束时

Clean Up Persistent Data at the Start of a Test, Not at the End

图像

每个测试在启动时都应将持久性存储初始化为已知状态。当单独运行测试时,它将在持久性存储中留下数据,这些数据可帮助您诊断测试失败。当作为套件的一部分运行时,下一个测试将首先清理持久状态,因此测试将彼此隔离。我们在“记录失败” (第 221页) 中使用了这种技术,在测试开始时启动应用程序之前清除日志。

Each test should initialize the persistent store to a known state when it starts. When a test is run individually, it will leave data in the persistent store that can help you diagnose test failures. When it is run as part of a suite, the next test will clean up the persistent state first, so tests will be isolated from each other. We used this technique in “Recording the Failure” (page 221) when we cleared the log before starting the application at the start of the test.

应将清理表的顺序集中到一个地方,因为随着数据库架构的发展,它必须保持最新状态。它是提取到下级对象中以供使用数据库的任何测试使用的理想候选对象:

The order in which tables must be cleaned up should be captured in one place because it must be kept up-to-date as the database schema evolves. It’s an ideal candidate to be extracted into a subordinate object to be used by any test that uses the database:

公共类 DatabaseCleaner {

私有静态最终类<?>[] ENTITY_TYPES = {

Customer.class,

PaymentMethod.class,

AuctionSiteCredentials.class,

AuctionSite.class,

Address.class

};

私有最终 EntityManager entityManager;



公共 DatabaseCleaner(EntityManager entityManager) {

this.entityManager = entityManager;

}



公共无效 clean() 抛出 SQLException {

EntityTransaction transaction = entityManager.getTransaction();

transaction.begin();



for (Class<?> entityType : ENTITY_TYPES ) {

deleteEntities(entityType);

}



transaction.commit();

}



私有无效 deleteEntities(Class<?> entityType) {

entityManager

.createQuery("从 " + entityNameOf(entityType)中删除)

.executeUpdate();

}

}

public class DatabaseCleaner {

private static final Class<?>[] ENTITY_TYPES = {

Customer.class,

PaymentMethod.class,

AuctionSiteCredentials.class,

AuctionSite.class,

Address.class

};

private final EntityManager entityManager;



public DatabaseCleaner(EntityManager entityManager) {

this.entityManager = entityManager;

}



public void clean() throws SQLException {

EntityTransaction transaction = entityManager.getTransaction();

transaction.begin();



for (Class<?> entityType : ENTITY_TYPES) {

deleteEntities(entityType);

}



transaction.commit();

}



private void deleteEntities(Class<?> entityType) {

entityManager

.createQuery("delete from " + entityNameOf(entityType))

.executeUpdate();

}

}

我们使用一个数组ENTITY_TYPES,来确保实体类型(以及数据库表)按照从数据库中删除行时不违反参照完整性的顺序进行清理。3我们添加DatabaseCleaner一个设置方法,在每次测试之前初始化数据库。例如:

We use an array, ENTITY_TYPES, to ensure that the entity types (and, therefore, database tables) are cleaned in an order that does not violate referential integrity when rows are deleted from the database.3 We add DatabaseCleaner to a setup method, to initialize the database before each test. For example:

3.我们省略了entityNameOf()这段代码。JPA 表示实体的名称源自其相关的 Java 类,但没有提供标准 API 来执行此操作。我们仅实现了足够的映射以使其DatabaseCleaner正常工作。

3. We’ve left entityNameOf() out of this code excerpt. The JPA says the the name of an entity is derived from its related Java class but doesn’t provide a standard API to do so. We implemented just enough of this mapping to allow DatabaseCleaner to work.

公共类 ExamplePersistenceTest {

final EntityManagerFactory factory =

Persistence.createEntityManagerFactory("example");

final EntityManager entityManager = factory.createEntityManager();



@Before

public void cleanDatabase() 抛出异常 {

new DatabaseCleaner(entityManager).clean();

}

[...]

}

public class ExamplePersistenceTest {

final EntityManagerFactory factory =

Persistence.createEntityManagerFactory("example");

final EntityManager entityManager = factory.createEntityManager();



@Before

public void cleanDatabase() throws Exception {

new DatabaseCleaner(entityManager).clean();

}

[...]

}

为简洁起见,我们不会在测试示例中展示此清理过程。您应该假设每个持久性测试都从数据库处于已知、干净的状态开始。

For brevity, we won’t show this cleanup in the test examples. You should assume that every persistence test starts with the database in a known, clean state.

明确测试事务边界

Make Tests Transaction Boundaries Explicit

隔离使用事务资源(如数据库)的测试的常用技术是将每个测试运行在一个事务中,然后在测试结束时回滚。这个想法是让持久状态在测试后与测试前保持一致。

A common technique to isolate tests that use a transactional resource (such as a database) is to run each test in a transaction which is then rolled back at the end of the test. The idea is to leave the persistent state the same after the test as before.

这种技术的问题在于它无法测试提交时发生的情况,而提交是重大事件。ORM 会将其在内存中管理的对象的状态刷新到数据库中。数据库则检查其完整性约束。永不提交的测试无法充分测试被测代码如何与数据库交互。它也无法测试不同事务之间的交互。回滚的另一个缺点是测试会丢弃可能有助于诊断故障的数据。

The problem with this technique is that it doesn’t test what happens on commit, which is a significant event. The ORM flushes the state of the objects it is managing in memory to the database. The database, in turn, checks its integrity constraints. A test that never commits does not fully exercise how the code under test interacts with the database. Neither can it test interactions between distinct transactions. Another disadvantage of rolling back is that the test discards data that might be useful for diagnosing failures.

测试应该明确描述事务。我们还希望突出事务边界,以便在阅读测试时很容易看到它们。我们通常将事务管理提取到一个称为transactor的下级对象中,该对象在事务内运行一个工作单元。在这种情况下,transactor 将协调 JPA 事务,因此我们将其称为JPATransactor4

Tests should explicitly delineate transactions. We also prefer to make transaction boundaries stand out, so they’re easy to see when reading the test. We usually extract transaction management into a subordinate object, called a transactor, that runs a unit of work within a transaction. In this case, the transactor will coordinate JPA transactions, so we call it a JPATransactor.4

4.在其他系统中,测试可能还使用JMSTransactor用于协调 Java 消息服务 (JMS) 代理中的事务,或JTATransactor用于通过标准 Java 事务 API (JTA) 协调分布式事务。

4. In other systems, tests might also use a JMSTransactor for coordinating transactions in a Java Messaging Service (JMS) broker, or a JTATransactor for coordinating distributed transactions via the standard Java Transaction API (JTA).

公共接口 UnitOfWork {

void work() 抛出异常;

}



公共类 JPATransactor {

私有最终 EntityManager entityManager;



公共 JPATransactor(EntityManager entityManager) {

this.entityManager = entityManager;

}



公共 void perform(UnitOfWork unitOfWork) 抛出异常 {

EntityTransaction transaction = entityManager.getTransaction();



transaction.begin();

尝试 {

unitOfWork.work();

transaction.commit();

}



catch (PersistenceException e) {

抛出 e;

}

catch (Exception e) {

transaction.rollback();

抛出 e;

}

}

}

public interface UnitOfWork {

void work() throws Exception;

}



public class JPATransactor {

private final EntityManager entityManager;



public JPATransactor(EntityManager entityManager) {

this.entityManager = entityManager;

}



public void perform(UnitOfWork unitOfWork) throws Exception {

EntityTransaction transaction = entityManager.getTransaction();



transaction.begin();

try {

unitOfWork.work();

transaction.commit();

}



catch (PersistenceException e) {

throw e;

}

catch (Exception e) {

transaction.rollback();

throw e;

}

}

}

通过传入来调用事务处理程序UnitOfWork,通常将其创建为匿名类:

The transactor is called by passing in a UnitOfWork, usually created as an anonymous class:

交易者.执行(new UnitOfWork(){

public void work()抛出异常{

customers.addCustomer(aNewCustomer());

}

});

transactor.perform(new UnitOfWork() {

public void work() throws Exception {

customers.addCustomer(aNewCustomer());

}

});

这种模式非常有用,我们也经常在生产代码中使用它。我们将在下一节中展示如何使用事务处理程序。

This pattern is so useful that we regularly use it in our production code as well. We’ll show more of how the transactor is used in the next section.

测试执行持久性操作的对象

Testing an Object That Performs Persistence Operations

现在我们有了一些测试框架,我们可以为执行持久性的对象编写测试。

Now that we’ve got some test scaffolding we can write tests for an object that performs persistence.

在我们的领域模型中,客户群代表我们了解的所有客户。我们可以将客户添加到我们的客户群中,并查找符合特定条件的客户。例如,我们需要找到信用卡即将到期的客户,以便向他们发送提醒以更新他们的付款详细信息。

In our domain model, a customer base represents all the customers we know about. We can add customers to our customer base and find customers that match certain criteria. For example, we need to find customers with credit cards that are about to expire so that we can send them a reminder to update their payment details.

公共接口 CustomerBase { [...]

void addCustomer(Customer customer);

List<Customer> customersWithExpiredCreditCardsAt(Date deadline);

}

public interface CustomerBase { [...]

void addCustomer(Customer customer);

List<Customer> customersWithExpiredCreditCardsAt(Date deadline);

}

CustomerBase当对调用来查找并通知相关客户的代码进行单元测试时,我们可以模拟该接口。然而,在已部署的系统中,此代码将调用CustomerBase由 JPA 支持的实际实现,以从数据库保存和加载客户信息。我们还必须测试此持久实现是否正常工作 — 它进行的查询和对象/关系映射是否正确。例如,下面是对查询的测试。事务中customersWithExpiredCreditCardsAt()有两个辅助方法与 交互:添加一组示例客户,并查询卡已过期的客户。customerBaseaddCustomer()assertCustomersExpiringOn()

When unit-testing code that calls a CustomerBase to find and notify the relevant customers, we can mock the interface. In a deployed system, however, this code will call a real implementation of CustomerBase that is backed by JPA to save and load customer information from a database. We must also test that this persistent implementation works correctly—that the queries it makes and the object/relational mappings are correct. For example, below is a test of the customersWithExpiredCreditCardsAt() query. There are two helper methods that interact with customerBase within a transaction: addCustomer() adds a set of example customers, and assertCustomersExpiringOn() queries for customers with expired cards.

公共类 PersistentCustomerBaseTest { [...]

final PersistentCustomerBase customerBase =

new PersistentCustomerBase(entityManager);

@Test

@SuppressWarnings("unchecked")

public void findsCustomersWithCreditCardsThatAreAboutToExpire() 抛出异常 {

final String deadline = "6 Jun 2009";



addCustomers(

aCustomer().withName("Alice (已过期)")

.withPaymentMethods(aCreditCard().withExpiryDate(date("2009 年 1 月 1 日"))),

aCustomer().withName("Bob (已过期)")

.withPaymentMethods(aCreditCard().withExpiryDate(date("2009 年 6 月 5 日"))),

aCustomer().withName("Carol (有效)")

.withPaymentMethods(aCreditCard().withExpiryDate(date(截止日期))),

aCustomer().withName("Dave (有效)")

.withPaymentMethods(aCreditCard().withExpiryDate(date("2009 年 6 月 7 日")))

);

assertCustomersExpiringOn(date(deadline),

containsInAnyOrder(customerNamed("Alice (已过期)"),

customerNamed("Bob (已过期)")));

}



private void addCustomers(final CustomerBuilder...customers) 抛出异常 {

交易者.perform(new UnitOfWork() {

public void work() 抛出异常 {

for (CustomerBuilder customer : customers) {

customerBase.addCustomer(customer.build());

}

}

});

}



private void assertCustomersExpiringOn(final Date date,

final Matcher<Iterable<Customer>> matcher)

抛出异常

{

交易者.perform(new UnitOfWork() {

public void work() 抛出异常 {

assertThat( customerBase.customersWithExpiredCreditCardsAsOf(date) , matcher);

}

});

}

}

public class PersistentCustomerBaseTest { [...]

final PersistentCustomerBase customerBase =

new PersistentCustomerBase(entityManager);

@Test

@SuppressWarnings("unchecked")

public void findsCustomersWithCreditCardsThatAreAboutToExpire() throws Exception {

final String deadline = "6 Jun 2009";



addCustomers(

aCustomer().withName("Alice (Expired)")

.withPaymentMethods(aCreditCard().withExpiryDate(date("1 Jan 2009"))),

aCustomer().withName("Bob (Expired)")

.withPaymentMethods(aCreditCard().withExpiryDate(date("5 Jun 2009"))),

aCustomer().withName("Carol (Valid)")

.withPaymentMethods(aCreditCard().withExpiryDate(date(deadline))),

aCustomer().withName("Dave (Valid)")

.withPaymentMethods(aCreditCard().withExpiryDate(date("7 Jun 2009")))

);

assertCustomersExpiringOn(date(deadline),

containsInAnyOrder(customerNamed("Alice (Expired)"),

customerNamed("Bob (Expired)")));

}



private void addCustomers(final CustomerBuilder... customers) throws Exception {

transactor.perform(new UnitOfWork() {

public void work() throws Exception {

for (CustomerBuilder customer : customers) {

customerBase.addCustomer(customer.build());

}

}

});

}



private void assertCustomersExpiringOn(final Date date,

final Matcher<Iterable<Customer>> matcher)

throws Exception

{

transactor.perform(new UnitOfWork() {

public void work() throws Exception {

assertThat(customerBase.customersWithExpiredCreditCardsAsOf(date), matcher);

}

});

}

}

我们调用addCustomers()s ,CustomerBuilder设置 s 以包含信用卡的名称和到期日期。到期日期是此测试的重要字段,因此我们创建到期日期在截止日期之前、当天和之后的客户,以演示边界条件。我们还设置了每个客户的名称以识别故障中的实例(请注意,名称自我描述了每个客户的相关状态)。按名称匹配的替代方法是使用每个对象的持久性标识符,该标识符由 JPA 分配。使用起来会更复杂(它不会作为 上的属性公开Customer),并且不是自我描述的。

We call addCustomers() with CustomerBuilders set up to include a name and an expiry date for the credit card. The expiry date is the significant field for this test, so we create customers with expiry dates before, on, and after the deadline to demonstrate the boundary condition. We also set the name of each customer to identify the instances in a failure (notice that the names self-describe the relevant status of each customer). An alternative to matching on name would have been to use each object’s persistence identifier, which is assigned by JPA. That would have been more complex to work with (it’s not exposed as a property on Customer), and would not be self-describing.

assertCustomersExpiringOn()方法运行我们针对给定截止时间测试的查询,并检查结果是否符合我们传入的 Hamcrest 匹配器。该containsInAnyOrder()方法返回一个匹配器,用于检查集合中每个元素是否有子匹配器。我们编写了一个customerNamed()方法来返回一个自定义匹配器,用于测试对象是否是具有给定名称的(附录 BCustomer中有更多关于自定义匹配器的内容)。因此,这个测试表明我们期望收到两个对象,分别名为和。Customer"Alice (Expired)""Bob (Expired)"

The assertCustomersExpiringOn() method runs the query we’re testing for the given deadline and checks that the result conforms to the Hamcrest matcher we pass in. The containsInAnyOrder() method returns a matcher that checks that there’s a sub-matcher for each of the elements in a collection. We’ve written a customerNamed() method to return a custom matcher that tests whether an object is a Customer with a given name (there’s more on custom matchers in Appendix B). So, this test says that we expect to receive back exactly two Customer objects, named "Alice (Expired)" and "Bob (Expired)".

测试CustomerBase.addCustomer()通过调用它来为查询设置数据库,从而隐式地执行测试。进一步思考,我们真正关心的是调用结果addCustomer()与后续查询之间的关系,因此我们可能不会进行addCustomer()独立测试。如果通过系统的某些addCustomer()功能看不到这种影响,那么在编写特殊测试查询来覆盖它之前,我们必须对其用途提出一些尖锐的问题。

The test implicitly exercises CustomerBase.addCustomer() by calling it to set up the database for the query. Thinking further, what we actually care about is the relationship between the result of calling addCustomer() and subsequent queries, so we probably won’t test addCustomer() independently. If there’s an effect of addCustomer() that is not visible through some feature of the system, then we’d have to ask some hard questions about its purpose before writing a special test query to cover it.

使用匹配器实现更好的测试结构

Better Test Structure with Matchers

图像

此测试包含一个使用 Hamcrest 创建干净测试结构的好例子。测试方法构造一个匹配器,它给出了查询的有效结果的简明描述。它将匹配器传递给assertCustomersExpiringOn(),后者只运行查询并将结果传递给匹配器。我们在测试方法(知道预期要检索的内容)和查询/断言方法(知道如何进行查询并可用于其他测试)之间进行了清晰的区分。

This test includes a nice example of using Hamcrest to create a clean test structure. The test method constructs a matcher, which gives a concise description of a valid result for the query. It passes the matcher to assertCustomersExpiringOn(), which just runs the query and passes the result to the matcher. We have a clean separation between the test method, which knows what is expected to be retrieved, and the query/assert method, which knows how to make a query and can be used in other tests.

PersistentCustomerBase以下是通过测试的实现:

Here is an implementation of PersistentCustomerBase that passes the test:

公共类 PersistentCustomerBase 实现 CustomerBase {

私有最终 EntityManager entityManager;



公共 PersistentCustomerBase(EntityManager entityManager) {

this.entityManager = entityManager;

}



公共 void addCustomer(Customer customer) {

entityManager.persist(customer);

}



公共 List<Customer> customersWithExpiredCreditCardsAt(Date deadline) {

查询 query = entityManager.createQuery(

"从 Customer c、CreditCardDetails d 中选择 c " +

"其中 d 是 c.paymentMethods 的成员 " +

" 和 d.expiryDate <:deadline");

query.setParameter("deadline", deadline);

返回 query.getResultList();

}

}

public class PersistentCustomerBase implements CustomerBase {

private final EntityManager entityManager;



public PersistentCustomerBase(EntityManager entityManager) {

this.entityManager = entityManager;

}



public void addCustomer(Customer customer) {

entityManager.persist(customer);

}



public List<Customer> customersWithExpiredCreditCardsAt(Date deadline) {

Query query = entityManager.createQuery(

"select c from Customer c, CreditCardDetails d " +

"where d member of c.paymentMethods " +

" and d.expiryDate < :deadline");

query.setParameter("deadline", deadline);

return query.getResultList();

}

}

这个实现看起来很简单 - 它比它的测试短得多 - 但是它依赖于许多我们没有包含的 XML 配置以及实现了简单 API 的第三方框架EntityManager

This implementation looks trivial—it’s so much shorter than its test—but it relies on a lot of XML configuration that we haven’t included and on a third-party framework that implements the EntityManager’s simple API.

测试对象是否可以持久保存

Testing That Objects Can Be Persisted

由于PersistentCustomerBase依赖太多配置和底层第三方代码,因此测试中的错误消息可能难以诊断。测试失败可能是由于查询中的缺陷、类的映射Customer、它使用的任何类的映射、ORM 的配置、无效的数据库连接参数或数据库本身的配置错误造成的。

The PersistentCustomerBase relies on so much configuration and underlying third-party code that the error messages from its test can be difficult to diagnose. A test failure could be caused by a defect in a query, the mapping of the Customer class, the mapping of any of the classes that it uses, the configuration of the ORM, invalid database connection parameters, or a misconfiguration of the database itself.

我们可以编写更多测试,帮助我们在发生持久性故障时查明原因。一个有用的测试是通过数据库“往返”所有持久性实体类型的实例,以检查每个类的映射是否配置正确。

We can write more tests to help us pinpoint the cause of a persistence failure when it occurs. A useful test is to “round-trip” instances of all persistent entity types through the database to check that the mappings are configured correctly for each class.

当我们以反射方式将对象转换为其他形式或从其他形式转换为对象时,往返测试非常有用。许多序列化和映射技术具有与 ORM 相同的优点和困难。映射可以通过紧凑、声明性代码或配置,但配置错误会导致难以诊断的缺陷。我们使用往返测试,以便快速识别此类缺陷的原因。

Round-trip tests are useful whenever we reflectively translate objects to and from other forms. Many serialization and mapping technologies have the same advantages and difficulties as ORM. The mapping can be defined by compact, declarative code or configuration, but misconfiguration creates defects that are difficult to diagnose. We use round-trip tests so we can quickly identify the cause of such defects.

持久对象往返传输

Round-Tripping Persistent Objects

我们可以使用“测试数据构建器”列表(第 257页)来表示持久实体类型。这样测试就可以轻松地实例化每个实例。我们还可以多次使用构建器类型,使用不同的设置,以创建在不同状态下往返或与其他实体具有不同关系的实体。

We can use a list of “test data builders” (page 257) to represent the persistent entity types. This makes it easy for the test to instantiate each instance. We can also use builder types more than once, with differing set-ups, to create entities for round-tripping in different states or with different relationships to other entities.

此测试循环遍历构建器列表(稍后我们将展示如何创建列表)。对于每个构建器,它会在一个事务中创建并持久化一个实体,并在另一个事务中检索和比较结果。与上一个测试一样,我们有两种transactor执行事务的方法。设置方法是persistedObjectFrom(),查询方法是assertReloadsWithSameStateAs()

This test loops through a list of builders (we’ll show how we create the list in a moment). For each builder, it creates and persists an entity in one transaction, and retrieves and compares the result in another. As in the last test, we have two transactor methods that perform transactions. The setup method is persistedObjectFrom() and the query method is assertReloadsWithSameStateAs().

公共类 PersistabilityTest { [...]

final List<? extends Builder<?>> persistentObjectBuilders = [...]



@Test public void roundTripsPersistentObjects() throws Exception {

for (Builder<?> builder : persistentObjectBuilders) {

assertCanBePersisted(builder);

}

}

private void assertCanBePersisted(Builder<?> builder) throws Exception {

try {

assertReloadsWithSameStateAs(persistedObjectFrom(builder));

} catch (PersistenceException e) {

抛出新的 PersistenceException("无法往返 " + typeNameFor(builder), e);

}

}

private Object persistedObjectFrom(final Builder<?> builder) throws Exception {

return transactor .performQuery(new QueryUnitOfWork() {

public Object query() throws Exception {

Object original = builder.build();

entityManager.persist(original);

return original;

}

});

}

private void assertReloadsWithSameStateAs(final Object original) 抛出异常 {

交易者.perform(new UnitOfWork() {

public void work() 抛出异常 {

assertThat(entityManager.find(original.getClass(), idOf(original));

hasSamePersistentFieldsAs(original));

}

});

}

private String typeNameFor(Builder<?> builder) {

return builder.getClass().getSimpleName().replace("Builder", "");

}

}

public class PersistabilityTest { [...]

final List<? extends Builder<?>> persistentObjectBuilders = [...]



@Test public void roundTripsPersistentObjects() throws Exception {

for (Builder<?> builder : persistentObjectBuilders) {

assertCanBePersisted(builder);

}

}

private void assertCanBePersisted(Builder<?> builder) throws Exception {

try {

assertReloadsWithSameStateAs(persistedObjectFrom(builder));

} catch (PersistenceException e) {

throw new PersistenceException("could not round-trip " + typeNameFor(builder), e);

}

}

private Object persistedObjectFrom(final Builder<?> builder) throws Exception {

return transactor.performQuery(new QueryUnitOfWork() {

public Object query() throws Exception {

Object original = builder.build();

entityManager.persist(original);

return original;

}

});

}

private void assertReloadsWithSameStateAs(final Object original) throws Exception {

transactor.perform(new UnitOfWork() {

public void work() throws Exception {

assertThat(entityManager.find(original.getClass(), idOf(original));

hasSamePersistentFieldsAs(original));

}

});

}

private String typeNameFor(Builder<?> builder) {

return builder.getClass().getSimpleName().replace("Builder", "");

}

}

The persistedObjectFrom()方法要求其给定的构建器创建一个实体实例,并将其保留在事务中。然后它将新实例返回给测试,以供以后比较;是允许我们从事务中返回值QueryUnitOfWork的变体。UnitOfWork

The persistedObjectFrom() method asks its given builder to create an entity instance which it persists within a transaction. Then it returns the new instance to the test, for later comparison; QueryUnitOfWork is a variant of UnitOfWork that allows us to return a value from a transaction.

assertReloadsWithSameStateAs()方法提取分配给预期对象的持久性标识符EntityManager(使用反射),并使用该标识符要求EntityManager从数据库中检索实体的另一个副本。然后它调用自定义匹配器,该匹配器使用反射来检查实体的两个副本在其持久字段中是否具有相同的值。

The assertReloadsWithSameStateAs() method extracts the persistence identifier that the EntityManager assigned to the expected object (using reflection), and uses that identifier to ask the EntityManager to retrieve another copy of the entity from the database. Then it calls a custom matcher that uses reflection to check that the two copies of the entity have the same values in their persistent fields.

返程投资相关实体

Round-Tripping Related Entities

当实体之间存在关系,并且一个实体的保存不会级联到其相关实体时,创建构建器列表会很复杂。当实体引用在事务期间从未创建的引用数据时,就会出现这种情况。

Creating a list of builders is complicated when there are relationships between entities, and saving of one entity is not cascaded to its related entities. This is the case when an entity refers to reference data that is never created during a transaction.

例如,我们的系统只知道有限数量的拍卖网站。客户有AuctionSiteCredentials引用这些网站的 。当系统创建一个Customer实体时,它会将其与AuctionSite从数据库加载的现有 关联。保存Customer将保存其AuctionSiteCredentials,但不会保存引用的 ,AuctionSite因为它们应该已经存在于数据库中。同时,我们必须将新的 关联AuctionSiteCredentialsAuctionSite数据库中已经存在的 ,否则我们在保存时将违反引用完整性约束。

For example, our system knows about a limited number of auction sites. Customers have AuctionSiteCredentials that refer to those sites. When the system creates a Customer entity, it associates it with existing AuctionSites that it loads from the database. Saving the Customer will save its AuctionSiteCredentials, but won’t save the referenced AuctionSites because they should already exist in the database. At the same time, we must associate a new AuctionSiteCredentials with an AuctionSite that is already in the database, or we will violate referential integrity constraints when we save.

解决方法是确保AuctionSite在保存新的之前,有一个持久化AuctionSiteCredentials。委托给另一个构建器来为正在构建的AuctionSiteCredentialsBuilder创建(请参阅第261页的“组合构建器” )。我们通过将构建器包装在装饰器[Gamma94]中来确保引用完整性,该装饰器在将与关联之前持久化。这就是我们在事务中调用实体构建器的原因——一些相关的构建器将执行需要活动事务的数据库操作。AuctionSiteAuctionSiteCredentialsAuctionSite AuctionSiteAuctionSiteCredentials

The fix is to make sure that there’s a persisted AuctionSite before we save a new AuctionSiteCredentials. The AuctionSiteCredentialsBuilder delegates to another builder to create the AuctionSite for the AuctionSiteCredentials under construction (see “Combining Builders” on page 261). We ensure referential integrity by wrapping the AuctionSite builder in a Decorator [Gamma94] that persists the AuctionSite before it is associated with the AuctionSiteCredentials. This is why we call the entity builder within a transaction—some of the related builders will perform database operations that require an active transaction.

公共类 PersistabilityTest { [...]

最终列表 <? 扩展 Builder <?>> persistentObjectBuilders = Arrays.asList(

新 AddressBuilder()、

新 PayMateDetailsBuilder()、

新 CreditCardDetailsBuilder()、

新 AuctionSiteBuilder()、

新 AuctionSiteCredentialsBuilder().forSite(持久化(新 AuctionSiteBuilder()))、

新 CustomerBuilder()

.usingAuctionSites(

新 AuctionSiteCredentialsBuilder().forSite(持久化(新 AuctionSiteBuilder())))

.withPaymentMethods(

新 CreditCardDetailsBuilder()、

新 PayMateDetailsBuilder()));

私有 <T> Builder<T> 持久化(最终 Builder<T> builder) {

返回新 Builder<T>() {

公共 T build() {

T entity = builder.build();

entityManager.persist(entity);

返回实体;

}

};

}

}

public class PersistabilityTest { [...]

final List<? extends Builder<?>> persistentObjectBuilders = Arrays.asList(

new AddressBuilder(),

new PayMateDetailsBuilder(),

new CreditCardDetailsBuilder(),

new AuctionSiteBuilder(),

new AuctionSiteCredentialsBuilder().forSite(persisted(new AuctionSiteBuilder())),

new CustomerBuilder()

.usingAuctionSites(

new AuctionSiteCredentialsBuilder().forSite(persisted(new AuctionSiteBuilder())))

.withPaymentMethods(

new CreditCardDetailsBuilder(),

new PayMateDetailsBuilder()));

private <T> Builder<T> persisted(final Builder<T> builder) {

return new Builder<T>() {

public T build() {

T entity = builder.build();

entityManager.persist(entity);

return entity;

}

};

}

}

但是数据库测试很慢!

But Database Tests Are S-l-o-w!

针对实际基础架构运行的测试比在内存中运行所有内容的单元测试要慢得多。我们可以通过定义持久性基础架构的干净接口(根据代码域定义)并使用模拟持久性实现(如我们在“仅模拟您拥有的类型”(第69页)中所述)来对我们的代码进行单元测试。然后,我们使用细粒度集成测试来测试此接口的实现,这样我们就不必启动整个系统来测试技术层。

Tests that run against realistic infrastructure are much slower than unit tests that run everything in memory. We can unit-test our code by defining a clean interface to the persistence infrastructure (defined in terms of our code’s domain) and using a mock persistence implementation—as we described in “Only Mock Types That You Own” (page 69). We then test the implementation of this interface with fine-grained integration tests so we don’t have to bring up the entire system to test the technical layers.

这样,我们就可以将测试组织成一系列阶段:在内存中快速运行的单元测试;速度较慢的集成测试,这些测试通常通过第三方 API 到达流程之外,并且依赖于外部服务(如数据库和消息代理)的配置;最后是针对打包并部署到类似生产环境中的系统运行的端到端测试。如果我们破坏了应用程序的核心逻辑,这将为我们带来快速反馈,并且以越来越粗的粒度级别提供有关集成的增量反馈。

This lets us organize our tests into a chain of phases: unit tests that run very quickly in memory; slower integration tests that reach outside the process, usually through third-party APIs, and that depend on the configuration of external services such as databases and messaging brokers; and, finally, end-to-end tests that run against a system packaged and deployed into a production-like environment. This gives us rapid feedback if we break the application’s core logic, and incremental feedback about integration at increasingly coarse levels of granularity.

第 26 章 单元测试和线程

Chapter 26. Unit Testing and Threads

仁慈的大自然注定人类的大脑无法同时思考两件事。

It is decreed by a merciful Nature that the human brain cannot think of two things simultaneously.

—阿瑟·柯南·道尔爵士

—Sir Arthur Conan Doyle

介绍

Introduction

无法回避:并发使事情变得复杂。这是进行测试驱动开发时面临的挑战。单元测试无法让您对系统质量充满信心,因为并发和同步是系统范围的问题。编写测试时,您必须担心在系统内部以及测试和系统之间是否正确同步。测试失败更难诊断,因为异常可能会被后台线程吞没,或者测试可能会超时而没有明确的解释。

There’s no getting away from it: concurrency complicates matters. It is a challenge when doing test-driven development. Unit tests cannot give you as much confidence in system quality because concurrency and synchronization are systemwide concerns. When writing tests, you have to worry about getting the synchronization right within the system and between the test and the system. Test failures are harder to diagnose because exceptions may be swallowed by background threads or tests may just time out with no clear explanation.

诊断和纠正现有代码中的同步问题非常困难,因此值得提前考虑系统的并发架构。您无需进行非常详细的设计,只需确定系统应对并发的大致架构和原则即可。

It’s hard to diagnose and correct synchronization problems in existing code, so it’s worth thinking about the system’s concurrency architecture ahead of time. You don’t need to design it in great detail, just decide on a broad-brush architecture and principles by which the system will cope with concurrency.

此设计通常由应用程序使用的框架或库规定。例如:

This design is often prescribed by the frameworks or libraries that an application uses. For example:

• Swing 在自己的线程上调度用户事件。如果事件处理程序运行时间过长,用户界面将变得无响应,因为 Swing 在事件处理程序运行时不会处理用户输入。事件回调必须生成“工作”线程来执行长时间运行的任务,并且这些工作线程必须与事件调度线程同步才能更新用户界面。

• Swing dispatches user events on its own thread. If an event handler runs for a long time, the user interface becomes unresponsive because Swing does not process user input while the event handler is running. Event call-backs must spawn “worker” threads to perform long-running tasks, and those worker threads must synchronize with the event dispatch thread to update the user interface.

• servlet 容器有一个线程池,用于接收 HTTP 请求并将其传递给 servlet 进行处理。多个线程可以同时在同一个 servlet 实例中处于活动状态。

• A servlet container has a pool of threads that receive HTTP requests and pass them to servlets for processing. Many threads can be active in the same servlet instance at once.

• Java EE 容器管理应用程序中的所有线程。容器保证每次只有一个线程调用组件。组件无法启动自己的线程。

• Java EE containers manage all the threading in the application. The container guarantees that only one thread will call into a component at a time. Components cannot start their own threads.

• Auction Sniper 应用程序使用的 Smack 库启动守护进程线程来接收 XMPP 消息。它将在单个线程上传递消息,但应用程序必须同步 Smack 线程和 Swing 线程,以避免 GUI 组件被损坏。

• The Smack library used by the Auction Sniper application starts a daemon thread to receive XMPP messages. It will deliver messages on a single thread, but the application must synchronize the Smack thread and the Swing thread to avoid the GUI components being corrupted.

当您必须从头开始设计系统的并发架构时,可以使用建模工具来证明您的设计不存在某些类型的同步错误,例如死锁、活锁或饥饿。帮助您建模并发的设计工具正变得越来越容易使用。《并发:状态模型和 Java 程序》 [Magee06]一书是一本并发编程的入门书,它强调形式化建模和实现的结合,并描述了如何使用 LTSA 分析工具进行形式化建模。

When you must design a system’s concurrency architecture from scratch, you can use modeling tools to prove your design free of certain classes of synchronization errors, such as deadlock, livelock, or starvation. Design tools that help you model concurrency are becoming increasingly easy to use. The book Concurrency: State Models & Java Programs [Magee06] is an introduction to concurrent programming that stresses a combination of formal modeling and implementation and describes how to do the formal modeling with the LTSA analysis tool.

然而,即使有了经过验证的设计,我们也必须跨越设计与实现之间的鸿沟。我们需要确保我们的组件符合系统的架构约束。此时,测试可以提供帮助。一旦我们设计了系统如何管理并发,我们就可以测试适合该架构的对象。单元测试让我们确信对象执行了其同步职责,例如锁定其状态或阻止和唤醒线程。粗粒度测试(例如系统测试)让我们确信整个系统可以正确管理并发。

Even with a proven design, however, we have to cross the chasm between design and implementation. We need to ensure that our components conform to the architectural constraints of the system. Testing can help at this point. Once we’ve designed how the system will manage concurrency, we can test-drive the objects that will fit into that architecture. Unit tests give us confidence that an object performs its synchronization responsibilities, such as locking its state or blocking and waking threads. Coarser-grained tests, such as system tests, give us confidence that the entire system manages concurrency correctly.

分离功能和并发策略

Separating Functionality and Concurrency Policy

处理多个线程的对象将功能问题与同步问题混合在一起,这两者都可能导致测试失败。测试还必须与后台线程同步,以便它们不会在线程完成工作之前做出断言,或者让线程继续运行,这可能会干扰后续测试。更糟糕的是,在存在线程的情况下,单元测试通常无法很好地报告失败。隐藏线程会抛出异常,意外地终止它们并破坏测试对象的行为。如果测试在等待后台线程完成时超时,通常除了基本的超时消息之外没有其他诊断信息。所有这些都使单元测试变得困难。

Objects that cope with multiple threads mix functional concerns with synchronization concerns, either of which can be the cause of test failures. Tests must also synchronize with the background threads, so that they don’t make assertions before the threads have finished working or leave threads running that might interfere with later tests. Worse, in the presence of threads, unit tests do not usually report failures well. Exceptions get thrown on the hidden threads, killing them unexpectedly and breaking the behavior of the tested object. If a test times out waiting for background threads to finish, there’s often no diagnostic other than a basic timeout message. All this makes unit testing difficult.

同时搜索拍卖

Searching for Auctions Concurrently

让我们看一个例子。我们将扩展我们的拍卖狙击手应用程序,让用户搜索感兴趣的拍卖。当用户输入搜索关键字时,应用程序将同时在其可以连接的所有拍卖行上运行搜索。每个拍卖行AuctionHouse都会返回一个列表AuctionDescription,其中包含与搜索关键字匹配的拍卖信息。应用程序将合并从所有拍卖行收到的结果AuctionHouse,并向用户显示单个拍卖列表。然后用户可以决定对其中哪个拍卖行进行竞标。

Let’s look at an example. We will extend our Auction Sniper application to let the user search for auctions of interest. When the user enters search keywords, the application will run the search concurrently on all auction houses that the application can connect to. Each AuctionHouse will return a list of AuctionDescriptions that contain information about its auctions matching the search keywords. The application will combine the results it receives from all AuctionHouses and display a single list of auctions to the user. The user can then decide which of them to bid for.

并发搜索由一个对象执行,AuctionSearch该对象将搜索关键字传递给每个对象AuctionHouse并宣布它们返回的结果AuctionSearchConsumer。我们对拍卖搜索的测试很复杂,因为AuctionSearch每次搜索都会产生多个线程,每个 一个AuctionHouse。如果它将这些线程隐藏在其 API 后面,我们将不得不同时实现搜索和通知功能以及同步。当测试失败时,我们必须找出是哪些问题导致了问题。这就是为什么我们更喜欢通过测试逐步添加功能的惯常做法。

The concurrent search is performed by an AuctionSearch object which passes the search keywords to each AuctionHouse and announces the results they return to an AuctionSearchConsumer. Our tests for the Auction Search are complicated because an AuctionSearch will spawn multiple threads per search, one for each AuctionHouse. If it hides those threads behind its API, we will have to implement the searching and notification functionality and the synchronization at the same time. When a test fails, we will have to work out which of those concerns is at fault. That’s why we prefer our usual practice of incrementally adding functionality test by test.

AuctionSearch如果我们可以分别处理功能行为和同步,那么测试和实现会更容易。这将使我们能够在测试线程内测试功能行为。我们希望将请求拆分为多个任务的逻辑与这些任务如何并发执行的技术细节分开。因此,我们将一个“任务运行器”传递给AuctionSearch,然后它可以将管理任务委托给运行器,而不是自己启动线程。在我们的单元测试中,我们将为提供AuctionSearch一个直接调用任务的假任务运行器。在实际系统中,我们将为它提供一个为任务创建线程的任务运行器。

It would be easier to test and implement the AuctionSearch if we could tackle the functional behavior and the synchronization separately. This would allow us to test the functional behavior within the test thread. We want to separate the logic that splits a request into multiple tasks from the technical details of how those tasks are executed concurrently. So we pass a “task runner” in to the AuctionSearch, which can then delegate managing tasks to the runner instead of starting threads itself. In our unit tests we’ll give the AuctionSearch a fake task runner that calls tasks directly. In the real system, we’ll give it a task runner that creates threads for tasks.

引入执行器

Introducing an Executor

我们需要和任务运行器之间的接口AuctionHouse。我们可以使用 Java 标准java.util.concurrent包中的这个接口:

We need an interface between the AuctionHouse and the task runner. We can use this one from Java’s standard java.util.concurrent package:

公共接口执行器 {

void execute(Runnable 命令);

}

public interface Executor {

void execute(Runnable command);

}

我们应该如何Executor在单元测试中实现?对于测试,我们需要在与测试运行器相同的线程中运行任务,而不是创建新的任务线程。我们可以使用 jMock 来模拟Executor并编写自定义操作来捕获所有调用,以便我们稍后可以运行它们,但这听起来太复杂了。最简单的选择是编写一个类来实现Executor。我们可以在对被测试对象的调用返回后明确使用它来在测试线程上运行任务。jMock 包含一个名为的类DeterministicExecutor。我们使用这个执行器来编写我们的第一个单元测试。它检查每当返回搜索结果时以及整个搜索完成时AuctionSearch通知它。AuctionSearchConsumerAuctionHouse

How should we implement Executor in our unit tests? For testing, we need to run the tasks in the same thread as the test runner instead of creating new task threads. We could use jMock to mock Executor and write a custom action to capture all calls so we can run them later, but that sounds too complicated. The easiest option is to write a class to implement Executor. We can use it explicitly to run the tasks on the test thread after the call to the tested object has returned. jMock includes such a class, called DeterministicExecutor. We use this executor to write our first unit test. It checks that AuctionSearch notifies its AuctionSearchConsumer whenever an AuctionHouse returns search results and when the entire search has finished.

在测试设置中,我们模拟消费者,因为我们想展示它是如何被通知的AuctionSearch。我们用一个简单的程序来表示拍卖行,StubAuctionHouse如果匹配关键字,则返回描述列表,否则返回空列表(真正的拍卖行会通过互联网与拍卖服务进行通信)。我们编写了一个自定义存根,而不是使用 jMock 津贴,以减少故障报告中的“噪音”;当我们在下一节开始压力测试时,您将看到这有多重要。我们还将一个实例传递给,DeterministicExecutor以便AuctionSearch我们可以在测试线程中运行任务。

In the test setup, we mock the consumer because we want to show how it’s notified by AuctionSearch. We represent auction houses with a simple StubAuctionHouse that just returns a list of descriptions if it matches keywords, or an empty list if not (real ones would communicate to auction services over the Internet). We wrote a custom stub, instead of using a jMock allowance, to reduce the “noise” in the failure reports; you’ll see how this matters when we start stress-testing in the next section. We also pass an instance of DeterministicExecutor to AuctionSearch so that we can run the tasks within the test thread.

@RunWith(JMock.class)

公共类 AuctionSearchTests {

Mockery 上下文 = new JUnit4Mockery();

最终DeterministicExecutor 执行器 = new DeterministicExecutor();

最终 StubAuctionHouse houseA = new StubAuctionHouse("houseA");

最终 StubAuctionHouse houseB = new StubAuctionHouse("houseB");



列表 <AuctionDescription> resultsFromA = asList(auction(houseA, "1"));

列表 <AuctionDescription> resultsFromB = asList(auction(houseB, "2"));;



最终 AuctionSearchConsumer 消费者 = context.mock(AuctionSearchConsumer.class);

最终 AuctionSearch 搜索 =

new AuctionSearch(执行器, houses(houseA, houseB), 消费者);



@Test 公共 void

searchingAllAuctionHouses() 抛出异常 {

最终 Set <String> keywords = set("sheep", "cheese");

houseA.willReturnSearchResults(关键词,resultsFromA);

houseB.willReturnSearchResults(关键词,resultsFromB);



context.checking(new Expectations() {{

final States searching = context.states("searching");



oneOf(consumer).auctionSearchFound(resultsFromA); when(searching.isNot("done"));

oneOf(consumer).auctionSearchFound(resultsFromB); when(searching.isNot("done"));

oneOf(consumer).auctionSearchFinished(); then(searching.is("done"));

}});



search.search(关键词);

executor.runUntilIdle();

}

}

@RunWith(JMock.class)

public class AuctionSearchTests {

Mockery context = new JUnit4Mockery();

final DeterministicExecutor executor = new DeterministicExecutor();

final StubAuctionHouse houseA = new StubAuctionHouse("houseA");

final StubAuctionHouse houseB = new StubAuctionHouse("houseB");



List<AuctionDescription> resultsFromA = asList(auction(houseA, "1"));

List<AuctionDescription> resultsFromB = asList(auction(houseB, "2"));;



final AuctionSearchConsumer consumer = context.mock(AuctionSearchConsumer.class);

final AuctionSearch search =

new AuctionSearch(executor, houses(houseA, houseB), consumer);



@Test public void

searchesAllAuctionHouses() throws Exception {

final Set<String> keywords = set("sheep", "cheese");

houseA.willReturnSearchResults(keywords, resultsFromA);

houseB.willReturnSearchResults(keywords, resultsFromB);



context.checking(new Expectations() {{

final States searching = context.states("searching");



oneOf(consumer).auctionSearchFound(resultsFromA); when(searching.isNot("done"));

oneOf(consumer).auctionSearchFound(resultsFromB); when(searching.isNot("done"));

oneOf(consumer).auctionSearchFinished(); then(searching.is("done"));

}});



search.search(keywords);

executor.runUntilIdle();

}

}

在测试中,我们将StubAuctionHouses 配置为在使用给定关键字查询时返回示例结果。我们指定我们的期望,即消费者将收到两个搜索结果(以任何顺序)的通知,然后搜索已完成。

In the test, we configure the StubAuctionHouses to return example results when they’re queried with the given keywords. We specify our expectations that the consumer will be notified of the two search results (in any order), and then that the search has finished.

当我们调用 时search.search(keywords),它AuctionSearch会将每个拍卖行的任务交给执行器。到search()返回时,要运行的任务已在执行器中排队。最后,我们调用executor.runUntilIdle()来告诉执行器运行排队的任务,直到其队列为空。任务在测试线程上运行,因此任何断言失败都将被 JUnit 捕获和报告,我们不必担心将测试线程与后台线程同步。

When we call search.search(keywords), the AuctionSearch hands a task for each of its auction houses to the executor. By the time search() returns, the tasks to run are queued in the executor. Finally, we call executor.runUntilIdle() to tell the executor to run queued tasks until its queue is empty. The tasks run on the test thread, so any assertion failures will be caught and reported by JUnit, and we don’t have to worry about synchronizing the test thread with background threads.

实施拍卖搜索

Implementing AuctionSearch

此实现调用AuctionSearchexecutor来开始搜索其每个拍卖行。它会跟踪其runningSearchCount领域中未完成的搜索数量,以便在搜索完成后通知消费者。

This implementation of AuctionSearch calls its executor to start a search for each of its auction houses. It tracks how many searches are unfinished in its runningSearchCount field, so that it can notify the consumer when it’s finished.

公共类 AuctionSearch {

私有最终 Executor 执行器;

私有最终 List<AuctionHouse> auctionHouses;

私有最终 AuctionSearchConsumer 消费者;



私有 int runningSearchCount = 0;



公共 AuctionSearch(Executor 执行器,

List<AuctionHouse> auctionHouses,

AuctionSearchConsumer 消费者)

{

this.executor = 执行器;

this.auctionHouses = auctionHouses;

this.consumer = 消费者;

}



公共无效搜索(设置<String> 关键字) {

for(AuctionHouse auctionHouse:auctionHouses) {

startSearching(auctionHouse,关键字);

}

}



私有无效 startSearching(最终 AuctionHouse auctionHouse,

最终 Set<String> 关键字)

{

runningSearchCount++;



执行器.execute(new Runnable() {

公共无效运行() {

搜索(auctionHouse,关键字);

}

});

}



私有 void 搜索(AuctionHouse auctionHouse,Set <String> 关键字){

消费者.auctionSearchFound(auctionHouse.findAuctions(关键字));



runningSearchCount--;

如果(runningSearchCount == 0){

消费者.auctionSearchFinished();

}

}

}

public class AuctionSearch {

private final Executor executor;

private final List<AuctionHouse> auctionHouses;

private final AuctionSearchConsumer consumer;



private int runningSearchCount = 0;



public AuctionSearch(Executor executor,

List<AuctionHouse> auctionHouses,

AuctionSearchConsumer consumer)

{

this.executor = executor;

this.auctionHouses = auctionHouses;

this.consumer = consumer;

}



public void search(Set<String> keywords) {

for (AuctionHouse auctionHouse : auctionHouses) {

startSearching(auctionHouse, keywords);

}

}



private void startSearching(final AuctionHouse auctionHouse,

final Set<String> keywords)

{

runningSearchCount++;



executor.execute(new Runnable() {

public void run() {

search(auctionHouse, keywords);

}

});

}



private void search(AuctionHouse auctionHouse, Set<String> keywords) {

consumer.auctionSearchFound(auctionHouse.findAuctions(keywords));



runningSearchCount--;

if (runningSearchCount == 0) {

consumer.auctionSearchFinished();

}

}

}

不幸的是,这个版本不安全,因为它不同步对 的访问runningSearchCount。不同的线程在减少该字段时可能会互相覆盖。到目前为止,我们已经明确了核心行为。我们将在下一个测试中消除这个同步问题。删除Executor给我们带来了两个好处。首先,它使开发更容易,因为我们可以对基本功能进行单元测试,而不会被线程问题弄糊涂。其次,对象的 API 不再隐藏其并发策略。

Unfortunately, this version is unsafe because it doesn’t synchronize access to runningSearchCount. Different threads may overwrite each other when they decrement the field. So far, we’ve clarified the core behavior. We’ll drive out this synchronization issue in the next test. Pulling out the Executor has given us two advantages. First, it makes development easier as we can unit-test the basic functionality without getting confused by threading issues. Second, the object’s API no longer hides its concurrency policy.

并发性是系统范围内的问题,应该在需要运行并发任务的对象之外进行控制。通过将适当的传递Executor给构造函数,我们遵循了“上下文独立性”设计原则。应用程序现在可以轻松地使对象适应应用程序的线程策略,而无需更改其实现。例如,如果我们需要限制活动线程的数量,我们可以引入线程池。

Concurrency is a system-wide concern that should be controlled outside the objects that need to run concurrent tasks. By passing an appropriate Executor to the constructor, we’re following the “context independence” design principle. The application can now easily adapt the object to the application’s threading policy without changing its implementation. For example, we could introduce a thread pool should we need to limit the number of active threads.

单元测试同步

Unit-Testing Synchronization

将功能和同步问题分开让我们能够单独测试我们的功能行为AuctionSearch。现在是时候测试同步了。我们将通过编写压力测试来实现这一点,该测试通过AuctionSearch实现运行多个线程来导致同步错误。如果没有对线程调度程序的精确控制,我们就无法保证我们的测试会发现同步错误。我们能做的最好的就是在足够多的线程上运行相同的代码足够多次,以使我们的测试有合理的概率检测到错误。

Separating the functional and synchronization concerns has let us test-drive the functional behavior of our AuctionSearch in isolation. Now it’s time to test-drive the synchronization. We will do this by writing stress-tests that run multiple threads through the AuctionSearch implementation to cause synchronization errors. Without precise control over the thread scheduler, we can’t guarantee that our tests will find synchronization errors. The best we can do is run the same code enough times on enough threads to give our tests a reasonable likelihood of detecting the errors.

设计压力测试的一种方法是考虑对象可观察行为的哪些方面与调用该对象的线程数无关。这些是对象在并发性方面的可观察不变量1通过关注这些不变量,我们可以调整测试中的线程数,而无需更改其断言。这为我们提供了编写压力测试的流程:

One approach to designing stress tests is to think about the aspects of an object’s observable behavior that are independent of the number of threads calling into the object. These are the object’s observable invariants with respect to concurrency.1 By focusing on these invariants, we can tune the number of threads in a test without having to change its assertions. This gives us a process for writing stress tests:

1 . 这与“契约式设计”中的不变量的使用以及并发建模的形式化方法不同。这些方法定义了对象状态的不变量。

1. This differs from the use of invariants in “design by contract” and formal methods of modeling concurrency. These define invariants over the object’s state.

• 指定对象在并发性方面的可观察不变量之一;

• Specify one of the object’s observable invariants with respect to concurrency;

• 为不变量编写压力测试,该测试从多个线程多次执行该对象;

• Write a stress test for the invariant that exercises the object multiple times from multiple threads;

• 观察测试是否失败,并调整压力测试,直到每次测试运行都可靠地失败;并且,

• Watch the test fail, and tune the stress test until it reliably fails on every test run; and,

• 通过添加同步使测试通过。

• Make the test pass by adding synchronization.

我们将通过一个例子来证明这一点。

We’ll demonstrate this with an example.

安全第一

Safety First

图像

在本章中,我们在介绍单元级压力测试之前先让功能行为的单元测试通过,因为这样我们就可以单独解释每种技术。然而,在实践中,我们经常在编写任何代码之前先编写功能单元测试和同步压力测试,确保它们失败,然后让它们都通过。这有助于我们避免签入通过测试但包含并发错误的代码。

In this chapter we have made the unit tests of functional behavior pass before we covered stress testing at the unit level because that allowed us to explain each technique on its own. In practice, however, we often write both a unit test for functionality and a stress test of the synchronization before writing any code, make sure they both fail, then make them both pass. This helps us avoid checking in code that passes its tests but contains concurrency errors.

拍卖搜索的压力测试

A Stress Test for AuctionSearch

我们有一个不变的原则AuctionSearch,那就是当搜索完成时,它只通知消费者一次,无论AuctionHouse它搜索了多少个 s - 也就是说,无论它启动了多少个线程。

One invariant of our AuctionSearch is that it notifies the consumer just once when the search has finished, no matter how many AuctionHouses it searches—that is, no matter how many threads it starts.

我们可以使用 jMock 为这个不变量编写压力测试。我们并不总是使用 jMock 进行压力测试,因为预期失败会干扰被测对象的线程。另一方面,当发生故障时,jMock 会报告对其模拟对象的实际调用顺序,这有助于诊断缺陷。它还提供了方便的设施,用于在测试线程和被测线程之间进行同步。

We can use jMock to write a stress test for this invariant. We don’t always use jMock for stress tests because expectation failures interfere with the threads of the object under test. On the other hand, jMock reports the actual sequence of calls to its mock objects when there is a failure, which helps diagnose defects. It also provides convenient facilities for synchronizing between the test thread and the threads being tested.

在 中AuctionSearchStressTests,我们设置了AuctionSearch一个线程池执行程序,它将在后台线程中运行任务,以及一个拍卖行列表,这些拍卖行被设置为与给定的关键字相匹配。jMock 默认情况下不是线程安全的,因此我们设置了Mockery一个Synchroniser,这是其线程策略的实现,它允许我们从不同的线程调用模拟对象。为了使测试调整更容易,我们在顶部定义了常量,表示在运行期间我们将应用的“压力程度”。

In AuctionSearchStressTests, we set up AuctionSearch with a thread-pool executor that will run tasks in background threads, and a list of auction houses stubbed to match on the given keywords. jMock is not thread-safe by default, so we set up the Mockery with a Synchroniser, an implementation of its threading policy that allows us to call mocked objects from different threads. To make tuning the test easier, we define constants at the top for the “degree of stress” we’ll apply during the run.

@RunWith(JMock.class)

公共类 AuctionSearchStressTests {

private static final int NUMBER_OF_AUCTION_HOUSES = 4;

private static final int NUMBER_OF_SEARCHES = 8;

private static final Set<String> KEYWORDS = setOf("sheep", "cheese");



final Synchroniser synchroniser = new Synchroniser() ;

final Mockery context = new JUnit4Mockery() {{

setThreadingPolicy( synchroniser );

}};

final AuctionSearchConsumer consumer = context.mock(AuctionSearchConsumer.class);

final States searching = context.states("searching");



final ExecutorService executor = Executors.newCachedThreadPool();

final AuctionSearch search = new AuctionSearch( executor , auctionHouses(), consumer);

[...]

private List<AuctionHouse> auctionHouses() {

ArrayList<AuctionHouse> auctionHouses = new ArrayList<AuctionHouse>();

for (int i = 0; i < NUMBER_OF_AUCTION_HOUSES; i++) {

auctionHouses.add(stubbedAuctionHouse(i));

}

返回 auctionHouses;

}



private AuctionHouse stubbedAuctionHouse(final int id) {

StubAuctionHouse house = new StubAuctionHouse("house" + id);

house.willReturnSearchResults(

KEYWORDS, asList(new AuctionDescription(house, "id" + id, "description")));

返回 house;

}

@Test(timeout=500) public void

onlyOneAuctionSearchFinishedNotificationPerSearch() 抛出异常 {

context.checking(new Expectations() {{

ignoring (consumer).auctionSearchFound(with(anyResults()));

});



for (int i = 0; i < NUMBER_OF_SEARCHES; i++) {

completeASearch();

}

}



private void completeASearch() 抛出 InterruptedException {

searching.startsAs("in progress");

context.checking(new Expectations() {{

exact(1).of(consumer).auctionSearchFinished(); then(searching.is("done"));

});



search.search(KEYWORDS);

synchroniser .waitUntil(searching.is("done"));

}



@After

public void cleanUp() 抛出 InterruptedException {

executor .shutdown();

executor .awaitTermination(1, SECONDS);

}

}

@RunWith(JMock.class)

public class AuctionSearchStressTests {

private static final int NUMBER_OF_AUCTION_HOUSES = 4;

private static final int NUMBER_OF_SEARCHES = 8;

private static final Set<String> KEYWORDS = setOf("sheep", "cheese");



final Synchroniser synchroniser = new Synchroniser();

final Mockery context = new JUnit4Mockery() {{

setThreadingPolicy(synchroniser);

}};

final AuctionSearchConsumer consumer = context.mock(AuctionSearchConsumer.class);

final States searching = context.states("searching");



final ExecutorService executor = Executors.newCachedThreadPool();

final AuctionSearch search = new AuctionSearch(executor, auctionHouses(), consumer);

[...]

private List<AuctionHouse> auctionHouses() {

ArrayList<AuctionHouse> auctionHouses = new ArrayList<AuctionHouse>();

for (int i = 0; i < NUMBER_OF_AUCTION_HOUSES; i++) {

auctionHouses.add(stubbedAuctionHouse(i));

}

return auctionHouses;

}



private AuctionHouse stubbedAuctionHouse(final int id) {

StubAuctionHouse house = new StubAuctionHouse("house" + id);

house.willReturnSearchResults(

KEYWORDS, asList(new AuctionDescription(house, "id" + id, "description")));

return house;

}

@Test(timeout=500) public void

onlyOneAuctionSearchFinishedNotificationPerSearch() throws Exception {

context.checking(new Expectations() {{

ignoring (consumer).auctionSearchFound(with(anyResults()));

}});



for (int i = 0; i < NUMBER_OF_SEARCHES; i++) {

completeASearch();

}

}



private void completeASearch() throws InterruptedException {

searching.startsAs("in progress");

context.checking(new Expectations() {{

exactly(1).of(consumer).auctionSearchFinished(); then(searching.is("done"));

}});



search.search(KEYWORDS);

synchroniser.waitUntil(searching.is("done"));

}



@After

public void cleanUp() throws InterruptedException {

executor.shutdown();

executor.awaitTermination(1, SECONDS);

}

}

在测试方法中onlyOneAuctionSearchFinishedNotificationPerSearch(),我们运行完整的搜索NUMBER_OF_SEARCHES次数,以增加发现任何竞争条件的可能性。它通过要求synchroniser等待直到收集完执行器启动的所有后台线程或直到超时来完成每次搜索。Synchroniser提供了一种可以安全地等待状态机处于(或不处于)给定状态的方法。测试忽略auctionSearchFound()通知,因为在这里我们只关心确保搜索干净地完成。最后,我们executor在测试拆卸中关闭。

In the test method onlyOneAuctionSearchFinishedNotificationPerSearch(), we run a complete search NUMBER_OF_SEARCHES times, to increase the likelihood of finding any race conditions. It finishes each search by asking synchroniser to wait until it’s collected all the background threads the executor has launched, or until it’s timed out. Synchroniser provides a method that will safely wait until a state machine is (or is not) in a given state. The test ignores auctionSearchFound() notifications, since here we’re only interested in making sure that the searches finish cleanly. Finally, we shut down executor in the test teardown.

观察压力测试是否失败很重要。即使测试对象存在同步漏洞,编写的测试也很容易通过。因此,我们在同步代码之前让测试失败,并检查是否获得了我们预期的失败报告,以此“测试测试”。如果没有,那么我们可能需要增加线程数或每个线程的迭代次数,直到我们可以相信测试能够揭示错误。2 然后我们添加同步以使测试通过。这是我们的测试失败:

It’s important to watch a stress test fail. It’s too easy to write a test that passes even though the tested object has a synchronization hole. So, we “test the test” by making it fail before we’ve synchronized the code, and checking that we get the failure report we expected. If we don’t, then we might need to raise the numbers of threads or iterations per thread until we can trust the test to reveal the error.2 Then we add the synchronization to make the test pass. Here’s our test failure:

2.当然,压力参数可能因环境而异,例如开发环境与构建环境。我们无法在此进行讨论,但需要指出的是,需要解决此问题。

2. Of course, the stress parameters may differ between environments, such as development vs. build. We can’t follow that through here, except to note that it needs addressing.

java.lang.AssertionError:意外调用:consumer.auctionSearchFinished()

期望:

允许,已调用 5 次:consumer.auctionSearchFound(ANYTHING)

预期一次,已调用 1 次:consumer.auctionSearchFinished();

然后搜索完成

预期一次,已调用 1 次:consumer.auctionSearchFinished();

然后搜索完成

状态:

搜索完成

在此之前发生了什么:

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseA, [...]

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseB, [...]

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseB, [...]

consumer.auctionSearchFinished()

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseA, [...]

consumer.auctionSearchFinished()

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseB, [...]

java.lang.AssertionError: unexpected invocation: consumer.auctionSearchFinished()

expectations:

allowed, already invoked 5 times: consumer.auctionSearchFound(ANYTHING)

expected once, already invoked 1 time: consumer.auctionSearchFinished();

then searching is done

expected once, already invoked 1 time: consumer.auctionSearchFinished();

then searching is done

states:

searching is done

what happened before this:

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseA,[...]

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseB,[...]

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseB,[...]

consumer.auctionSearchFinished()

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseA,[...]

consumer.auctionSearchFinished()

consumer.auctionSearchFound(<[AuctionDescription[auctionHouse=houseB,[...]

这说明AuctionSearch呼叫auctionFinished()次数过多。

This says that AuctionSearch has called auctionFinished() once too often.

修复竞争条件(两次)

Fixing the Race Condition (Twice)

我们尚未同步对 的访问runningSearchCount。如果我们使用AtomicIntegerJava 并发库中的 而不是普通的int,则线程应该能够减少它而不会互相干扰。

We haven’t synchronized access to runningSearchCount. If we use an AtomicInteger from the Java concurrency libraries instead of a plain int, the threads should be able to decrement it without interfering with each other.

公共类 AuctionSearch { [...]

私有最终 AtomicInteger runningSearchCount = new AtomicInteger();



公共无效搜索(设置 <String> 关键字){

for(AuctionHouse auctionHouse:auctionHouses){

startSearching(auctionHouse,关键字);

}

}



私有无效 startSearching(最终 AuctionHouse auctionHouse,

最终设置 <String> 关键字)

{

runningSearchCount.incrementAndGet();



执行器.execute(新 Runnable(){

公共无效运行(){ 搜索(auctionHouse,关键字); }

});

}



私有无效搜索(AuctionHouse auctionHouse,设置 <String> 关键字){

消费者.auctionSearchFound(auctionHouse.findAuctions(关键字));



如果(runningSearchCount.decrementAndGet()==0){

消费者.auctionSearchFinished();

} }

}

public class AuctionSearch { [...]

private final AtomicInteger runningSearchCount = new AtomicInteger();



public void search(Set<String> keywords) {

for (AuctionHouse auctionHouse : auctionHouses) {

startSearching(auctionHouse, keywords);

}

}



private void startSearching(final AuctionHouse auctionHouse,

final Set<String> keywords)

{

runningSearchCount.incrementAndGet();



executor.execute(new Runnable() {

public void run() { search(auctionHouse, keywords); }

});

}



private void search(AuctionHouse auctionHouse, Set<String> keywords) {

consumer.auctionSearchFound(auctionHouse.findAuctions(keywords));



if (runningSearchCount.decrementAndGet() == 0) {

consumer.auctionSearchFinished();

}

}

}

我们尝试这样做,尽管我们使用了AtomicInteger,我们的测试仍然失败!毕竟我们的同步还是不正确。

We try this and, in spite of our use of an AtomicInteger, our test still fails! We haven’t got our synchronization right after all.

我们再次查看失败,发现现在AuctionSearch报告搜索已完成不止一次。之前,不安全的并发访问runningSearchCount导致通知数量少于 auctionSearchFinished()预期,因为AuctionSearch丢失了对字段的更新。一定是其他问题。

We look again at the failure and see that now the AuctionSearch is reporting that the search has finished more than once per search. Previously, the unsafe concurrent access to runningSearchCount resulted in fewer auctionSearchFinished() notifications than expected, because AuctionSearch was losing updates to the field. Something else must be wrong.

作为一名眼尖的读者,您会注意到AuctionSearch递增和递减方式中的竞争条件。它在启动任务线程之前runningSearchCount递增计数。一旦主线程开始创建任务线程,线程调度程序就可以抢占它并开始运行任何准备就绪的任务线程 - 而主线程仍有搜索任务要创建。如果所有这些已启动的任务线程在调度程序恢复主线程之前完成,它们将把计数递减为,最后一个线程将发送通知。当主线程最终恢复时,它将继续启动其剩余的搜索,这最终将触发另一个通知。图像auctionSearchFinshed()

As an eagle-eyed reader, you’ll have noticed a race condition in the way AuctionSearch increments and decrements runningSearchCount. It increments the count before starting a task thread. Once the main thread has started creating task threads, the thread scheduler can preëmpt it and start running whatever task threads are ready—while the main thread still has search tasks left to create. If all these started task threads complete before the scheduler resumes the main thread, they will decrement the count to and the last one will send an auctionSearchFinshed() notification. When the main thread finally resumes, it will continue by starting its remaining searches, which will eventually trigger another notification.

这种错误说明了我们为什么需要编写压力测试,以确保我们看到它们失败,并理解失败消息——这也是我们编写易于理解的失败报告的良好动机。这个例子还强调了将“原始”功能测试与线程测试分开的好处。随着单线程版本的稳定,我们知道我们可以专注于在压力测试中寻找竞争条件。

This sort of error shows why we need to write stress tests, to make sure that we see them fail, and to understand the failure messages—it’s also a good motivation for us to write comprehensible failure reports. This example also highlights the benefits of splitting tests of “raw” functionality from threaded tests. With the single-threaded version stable, we know we can concentrate on looking for race conditions in the stress tests.

runningSearchCount我们通过在启动任何线程之前设置预期的搜索次数来修复代码:

We fix the code by setting runningSearchCount to the expected number of searches before starting any threads:

public class AuctionSearch { [...]

public void search(Set<String> keywords) {

runningSearchCount.set(auctionHouses.size());



for (AuctionHouse auctionHouse : auctionHouses) {

startSearching(auctionHouse, keywords);

}

}



private void startSearching(final AuctionHouse auctionHouse,

final Set<String> keywords)

{

// 不再在此处增加计数

executor.execute(new Runnable() {

public void run() { search(auctionHouse, keywords); }

});

}

}

public class AuctionSearch { [...]

public void search(Set<String> keywords) {

runningSearchCount.set(auctionHouses.size());



for (AuctionHouse auctionHouse : auctionHouses) {

startSearching(auctionHouse, keywords);

}

}



private void startSearching(final AuctionHouse auctionHouse,

final Set<String> keywords)

{

// no longer increments the count here

executor.execute(new Runnable() {

public void run() { search(auctionHouse, keywords); }

});

}

}

对被动对象进行压力测试

Stress-Testing Passive Objects

AuctionSearch通过调用其 主动启动多个线程executor。但是,大多数与线程有关的对象不会自己启动线程,而是让多个线程“穿过”它们并改变它们的状态。例如,Servlet 需要支持接触同一实例的多个线程。在这种情况下,对象必须同步对可能导致竞争条件的任何状态的访问。

AuctionSearch actively starts multiple threads by calling out to its executor. Most objects that are concerned with threading, however, don’t start threads themselves but have multiple threads “pass through” them and alter their state. Servlets, for example, are required to support multiple threads touching the same instance. In such cases, an object must synchronize access to any state that might cause a race condition.

要对被动对象的同步进行压力测试,测试必须启动自己的线程来调用该对象。当所有线程都完成后,对象的状态应该与这些调用按顺序发生时相同。例如,AtomicBigCounter下面的代码不同步对其count变量的访问。它在从单个线程调用时可以正常工作,但在从多个线程调用时可能会丢失更新:

To stress-test the synchronization of a passive object, the test must start its own threads to call the object. When all the threads have finished, the state of the object should be the same as if those calls had happened in sequence. For example, AtomicBigCounter below does not synchronize access to its count variable. It works when called from a single thread but can lose updates when called from multiple threads:

公共类 AtomicBigCounter {

私有 BigInteger count = BigInteger.ZERO;



公共 BigInteger count() { 返回 count; }

公共 void inc() { count = count.add(BigInteger.ONE); }

}

public class AtomicBigCounter {

private BigInteger count = BigInteger.ZERO;



public BigInteger count() { return count; }

public void inc() { count = count.add(BigInteger.ONE); }

}

我们可以通过从多个线程调用inc()足够多的次数来显示此失败,这给我们带来了导致竞争条件和丢失更新的好机会。当发生这种情况时,的最终结果count()将小于我们调用的次数inc()

We can show this failure by calling inc() from multiple threads enough times to give us a good chance of causing the race condition and losing an update. When this happens, the final result of count() will be less than the number of times we’ve called inc().

我们可以在测试中直接启动多个线程,但启动和同步线程的细节混乱会妨碍理解意图。线程问题很适合提取到下级对象MultiThreadedStressTester我们用它来调用计数器的inc()方法:

We could spin up multiple threads directly in our test, but the mess of detail for launching and synchronizing threads would get in the way of understanding the intent. The threading concerns are a good candidate for extracting into a subordinate object, MultiThreadedStressTester, which we use to call the counter’s inc() method:

公共类 AtomicBigCounterTests { [...]

最终 AtomicBigCounter 计数器 = 新 AtomicBigCounter();



@Test 公共 void

canIncrementCounterFromMultipleThreadsSimultaneously() 抛出 InterruptedException {

MultithreadedStressTester StressTester = 新 MultithreadedStressTester(50000);



StressTester.stress(新 Runnable() {

公共 void run() {

counter.inc();

}

});

StressTester.shutdown();



assertThat("最终计数", counter.count(),

equalTo(BigInteger.valueOf(stressTester.totalActionCount())));

}

}

public class AtomicBigCounterTests { [...]

final AtomicBigCounter counter = new AtomicBigCounter();



@Test public void

canIncrementCounterFromMultipleThreadsSimultaneously() throws InterruptedException {

MultithreadedStressTester stressTester = new MultithreadedStressTester(50000);



stressTester.stress(new Runnable() {

public void run() {

counter.inc();

}

});

stressTester.shutdown();



assertThat("final count", counter.count(),

equalTo(BigInteger.valueOf(stressTester.totalActionCount())));

}

}

测试失败,显示竞争条件AtomicBigCounter

The test fails, showing the race condition in AtomicBigCounter:

java.lang.AssertionError:最终计数

预期:<50000>

得到:<36933>

java.lang.AssertionError: final count

Expected: <50000>

got: <36933>

inc()我们通过使和方法同步来通过测试count()

We pass the test by making the inc() and count() methods synchronized.

将测试线程与后台线程同步

Synchronizing the Test Thread with Background Threads

在为启动线程的代码编写测试时,测试无法确认代码的行为,直到它将其线程与代码启动的任何线程同步。例如,AuctionSearchStressTests我们让测试线程等待,直到启动的所有任务线程AuctionSearch都已完成。与后台线程同步可能具有挑战性,特别是如果测试对象没有委托执行器来运行并发任务。

When writing a test for code that starts threads, the test cannot confirm the code’s behavior until it has synchronized its thread with any threads the code has started. For example, in AuctionSearchStressTests we make the test thread wait until all the task threads launched by AuctionSearch have been completed. Synchronizing with background threads can be challenging, especially if the tested object does not delegate to an executor to run concurrent tasks.

确保线程已完成的最简单方法是让测试休眠足够长的时间,以便所有线程都运行完成。例如:

The easiest way to ensure that threads have finished is for the test to sleep long enough for them all to run to completion. For example:

图像

这种方法偶尔使用时是可行的 — 少数测试中的亚秒级延迟不会引人注意 — 但这种方法无法扩展。随着延迟测试数量的增加,总延迟会累积起来,测试套件的速度会大大减慢,以至于运行测试套件会让人分心。我们必须能够快速运行所有单元测试,以至于我们甚至不会考虑是否应该运行它们。固定休眠的另一个问题是,我们对延迟的选择必须适用于测试运行的所有环境。适合性能不足的机器的延迟会减慢其他地方的测试速度,而引入新环境可能会迫使进行另一轮调整。

This works for occasional use—a sub-second delay in a few tests won’t be noticeable—but it doesn’t scale. As the number of tests with delays grows, the total delay adds up and the test suite slows down so much that running it becomes a distraction. We must be able to run all the unit tests so quickly as to not even think about whether we should. The other problem with fixed sleeps is that our choice of delay has to apply across all the environments where the tests run. A delay suitable for an underpowered machine will slow the tests everywhere else, and introducing a new environment may force another round of tuning.

另一种方法AuctionSearchStressTests是使用 jMock 的,正如我们在 中看到的Synchroniser。它支持根据状态机是否进入或离开给定状态来同步测试线程和后台线程:

An alternative, as we saw in AuctionSearchStressTests, is to use jMock’s Synchroniser. It provides support for synchronizing between test and background threads, based on whether a state machine has entered or left a given state:

synchroniser.waitUntil(searching.is("已完成"));

synchroniser.waitUntil(searching.isNot("正在进行中"));

synchroniser.waitUntil(searching.is("finished"));

synchroniser.waitUntil(searching.isNot("in progress"));

由于测试失败,状态机永远不会满足指定的标准,因此这些方法将永远阻止,因此应在测试声明中添加超时时间:

These methods will block forever for a failing test, where the state machine never meets the specified criteria, so they should be used with a timeout added to the test declaration:

@测试(超时=500)

@Test(timeout=500)

如果测试超出超时期限,这将告诉测试运行器强制失败。

This tells the test runnner to force a failure if the test overruns the timeout period.

如果成功,测试将尽可能快地运行(Synchroniser的实现基于 Java 监视器),并且仅在失败时等待整个 500 毫秒。因此,大多数情况下,同步不会减慢测试套件的速度。

A test will run as fast as possible if successful (Synchroniser’s implementation is based on Java monitors), and only wait the entire 500 ms for failures. So, most of the time, the synchronization will not slow down the test suite.

如果不使用 jMock,您可以编写一个类似的实用程序来Synchroniser同步测试线程和后台线程。或者,我们在第 27 章中描述了其他同步技术。

If not using jMock, you can write a utility similar to Synchroniser to synchronize between test and background threads. Alternatively, we describe other synchronization techniques in Chapter 27.

单位压力测试的局限性

The Limitations of Unit Stress Tests

为对象的同步行为设置一组单独的测试有助于我们在测试失败时准确找出缺陷所在。使用调试器诊断竞争条件非常困难,因为单步执行代码(甚至添加打印语句)都会改变导致冲突的线程调度。3如果某个更改导致压力测试失败,但功能单元测试仍然通过,至少我们知道该对象的功能逻辑是正确的,而我们在其同步中引入了缺陷,反之亦然。

Having a separate set of tests for our object’s synchronization behavior helps us pinpoint where to look for defects if tests fail. It is very difficult to diagnose race conditions with a debugger, as stepping through code (or even adding print statements) will alter the thread scheduling that’s causing the clash.3 If a change causes a stress test to fail but the functional unit tests still pass, at least we know that the object’s functional logic is correct and we’ve introduced a defect into its synchronization, or vice versa.

3.这些被称为“Heisenbug”,因为尝试检测错误会改变它。

3. These are known as “Heisenbugs,” because trying to detect the bug alters it.

显然,压力测试只能在一定程度上确保代码是线程安全的,而不是保证。不同的操作系统(或操作系统的版本)和不同的处理器组合之间可能存在调度差异。此外,主机上可能还有其他进程会影响测试运行时的调度。我们能做的最好的事情是在各种环境中频繁运行测试 - 在提交新代码之前在本地运行,在提交后在多个构建服务器上运行。这应该已经是开发过程的一部分。我们可以调整测试中的工作量和线程数,直到它们在检测错误方面足够可靠 - 其中“足够”的含义是团队的工程决策。

Obviously, stress tests offer only a degree of reassurance that code is thread-safe, not a guarantee. There may be scheduling differences between different operating systems (or versions of an operating system) and between different processor combinations. Further, there may be other processes on a host that affect scheduling while the tests are running. The best we can do is to run the tests frequently in a range of environments—locally before committing new code, and on multiple build servers after commit. This should already be part of the development process. We can tune the amount of work and number of threads in the tests until they are reliable enough at detecting errors—where the meaning of “enough” is an engineering decision for the team.

为了保护自己,我们采取了“双保险”的方法。4我们运行单元测试来检查我们的对象是否正确同步并发线程并查明同步失败。我们运行端到端测试来检查单元级同步策略是否集成在整个系统中。如果我们使用的框架没有强加给我们并发架构,我们有时会使用正式的建模工具(如[Magee06]中描述的 LTSA 工具)来证明我们的并发模型避免了某些类型的错误。最后,我们运行静态分析工具作为自动构建过程的一部分来捕获进一步的错误。现在有一些很好的实际例子,比如 Findbugs,5可以检测日常 Java 代码中的同步错误。

To cover our backs, we take a “belt and braces” approach.4 We run unit tests to check that our objects correctly synchronize concurrent threads and to pinpoint synchronization failures. We run end-to-end tests to check that unit-level synchronization policies integrate across the entire system. If the concurrency architecture is not imposed on us by the frameworks we are using, we sometimes use formal modeling tools, such as the LTSA tool described in [Magee06], to prove that our concurrency model avoids certain classes of errors. Finally, we run static analysis tools as part of our automated build process to catch further errors. There are now some excellent practical examples, such as Findbugs,5 that can detect synchronization errors in everyday Java code.

4.对于美国读者来说,这意味着“皮带和吊带”,但吊带在英式英语中是一种截然不同的服装。

4. For American readers, this means “belt and suspenders,” but suspenders are a significantly different garment in British English.

5. http://findbugs.sf.net

5. http://findbugs.sf.net

在本章中,我们讨论了并发代码的单元级测试。大规模并发行为测试要复杂得多 — 测试代码可能在多个分布式进程中运行;测试设置可能无法使用执行器控制线程的创建;某些同步事件可能不易检测到;并且,系统可能会在错误报告给测试之前检测并吞掉错误。我们将在下一章中讨论这一级别的测试。

In this chapter, we’ve considered unit-level testing of concurrent code. Larger-scale testing of concurrent behavior is much more complex—the tested code might be running in multiple, distributed processes; the test setup might not be able to control the creation of threads with an executor; some of the synchronization events might not be easily detectable; and, the system might detect and swallow errors before they can be reported to a test. We address this level of testing in the next chapter.

第 27 章 测试异步代码

Chapter 27. Testing Asynchronous Code

我可以拼写香蕉,但我不知道什么时候该停止。

I can spell banana but I never know when to stop.

— 约翰尼·默瑟 (词曲作者)

—Johnny Mercer (songwriter)

介绍

Introduction

有些测试必须应对异步行为 — 无论是从外部探测系统的端到端测试,还是我们刚刚看到的执行多线程代码的单元测试。这些测试会触发系统内的一些活动,使其与测试线程同时运行。与没有并发性的“普通”测试的关键区别在于,控制权在测试活动完成之前返回到测试 — 从对目标代码的调用返回并不意味着它已准备好进行检查。

Some tests must cope with asynchronous behavior—whether they’re end-to-end tests probing a system from the outside or, as we’ve just seen, unit tests exercising multithreaded code. These tests trigger some activity within the system to run concurrently with the test’s thread. The critical difference from “normal” tests, where there is no concurrency, is that control returns to the test before the tested activity is complete—returning from the call to the target code does not mean that it’s ready to be checked.

例如,此测试假设方法返回Set时已完成添加元素add()。断言set大小为 1 可验证它未存储重复元素。

For example, this test assumes that a Set has finished adding an element when the add() method returns. Asserting that set has a size of one verifies that it did not store duplicate elements.

@Test public void storesUniqueElements() {

Set set = new HashSet<String>();



set.add("bananana");

set.add("bananana");



assertThat(set.size(), equalTo(1));

}

@Test public void storesUniqueElements() {

Set set = new HashSet<String>();



set.add("bananana");

set.add("bananana");



assertThat(set.size(), equalTo(1));

}

相比之下,该系统测试是异步的。该holdingOfStock()方法通过 HTTP 同步下载股票报告,但该send()方法向服务器发送异步消息以更新其持有的股票记录。

By contrast, this system test is asynchronous. The holdingOfStock() method synchronously downloads a stock report by HTTP, but the send() method sends an asynchronous message to a server that updates its records of stocks held.

@Test public void buyAndSellOfSameStockOnSameDayCancelsOutOurHolding() {

Date tradeDate = new Date();



发送(aTradeEvent()。ofType(BUY)。onDate(tradeDate)。forStock("A")。withQuantity(10));

发送(aTradeEvent()。ofType(SELL)。onDate(tradeDate)。forStock("A")。withQuantity(10));



断言(holdingOfStock("A", tradeDate), equalTo(0));

}

@Test public void buyAndSellOfSameStockOnSameDayCancelsOutOurHolding() {

Date tradeDate = new Date();



send(aTradeEvent().ofType(BUY).onDate(tradeDate).forStock("A").withQuantity(10));

send(aTradeEvent().ofType(SELL).onDate(tradeDate).forStock("A").withQuantity(10));



assertThat(holdingOfStock("A", tradeDate), equalTo(0));

}

交易消息的传输和处理与测试同时进行,因此服务器可能尚未收到或处理消息当测试做出断言时。断言检查的库存价值将取决于时间:消息到达服务器需要多长时间、服务器更新其数据库需要多长时间以及测试运行需要多长时间。测试可能会在两条消息都处理完后(正确通过)、一条消息处理完后(错误失败)或在一条消息处理完前(通过,但什么都不测试)触发断言。

The transmission and processing of a trade message happens concurrently with the test, so the server might not have received or processed the messages yet when the test makes its assertion. The value of the stock holding that the assertion checks will depend on timings: how long the messages take to reach the server, how long the server takes to update its database, and how long the test takes to run. The test might fire the assertion after both messages have been processed (passing correctly), after one message (failing incorrectly), or before either message (passing, but testing nothing at all).

从这个小例子可以看出,使用异步测试时,我们必须小心它与被测系统的协调。否则,它可能会变得不可靠,在系统正常工作时间歇性失败,或者更糟的是,在系统崩溃时测试通过。

As you can see from this small example, with an asynchronous test we have to be careful about its coordination with the system it’s testing. Otherwise, it can become unreliable, failing intermittently when the system is working or, worse, passing when the system is broken.

当前的测试框架很少支持处理异步。它们大多假设测试在单个控制线程中运行,让程序员构建测试并发行为所需的框架。在本章中,我们将介绍一些为异步代码编写可靠、响应迅速的测试的实践。

Current testing frameworks provide little support for dealing with asynchrony. They mostly assume that the tests run in a single thread of control, leaving the programmer to build the scaffolding needed to test concurrent behavior. In this chapter we describe some practices for writing reliable, responsive tests for asynchronous code.

采样或聆听

Sampling or Listening

测试异步代码的根本困难在于,测试会触发与测试同时运行的活动,因此无法立即检查活动的结果。测试不会阻塞,直到活动完成。如果活动失败,它不会将异常抛回测试中,因此测试无法识别活动是否仍在运行或已失败。因此,测试必须等待活动成功完成,如果在给定的超时期限内没有成功完成,则测试失败。

The fundamental difficulty with testing asynchronous code is that a test triggers activity that runs concurrently with the test and therefore cannot immediately check the outcome of the activity. The test will not block until the activity has finished. If the activity fails, it will not throw an exception back into the test, so the test cannot recognize if the activity is still running or has failed. The test therefore has to wait for the activity to complete successfully and fail if this doesn’t happen within a given timeout period.

等待成功

Wait for Success

图像

异步测试必须等待成功并使用超时来检测失败。

An asynchronous test must wait for success and use timeouts to detect failure.

这意味着每个测试活动都必须具有可观察到的效果:测试必须影响系统,使其可观察到的状态变得不同。这听起来很明显,但它决定了我们如何思考编写异步测试。如果活动没有可观察到的效果,测试就无法等待任何东西,因此测试无法与正在测试的系统同步。

This implies that every tested activity must have an observable effect: a test must affect the system so that its observable state becomes different. This sounds obvious but it drives how we think about writing asynchronous tests. If an activity has no observable effect, there is nothing the test can wait for, and therefore no way for the test to synchronize with the system it is testing.

测试可以通过两种方式观察系统:通过对其可观察状态进行采样或通过监听其发出的事件。其中,采样通常是唯一的选择,因为许多系统不发送任何监控事件。测试包含两种技术以与其系统的不同“端”进行交互是很常见的。例如,Auction Sniper 端到端测试通过 WindowLicker 框架对用户界面进行采样以查看显示变化,但监听虚假拍卖服务器中的聊天事件。

There are two ways a test can observe the system: by sampling its observable state or by listening for events that it sends out. Of these, sampling is often the only option because many systems don’t send any monitoring events. It’s quite common for a test to include both techniques to interact with different “ends” of its system. For example, the Auction Sniper end-to-end tests sample the user interface for display changes, through the WindowLicker framework, but listen for chat events in the fake auction server.

警惕闪烁测试

Beware of Flickering Tests

图像

如果测试的超时时间太接近测试行为正常运行所需的时间,或者测试无法与系统正确同步,则测试可能会间歇性失败。在小型系统上,偶尔的闪烁测试可能不会造成问题 - 测试很可能会在下一次构建期间通过 - 但这样做是有风险的。随着测试套件的增长,要让测试运行中没有闪烁测试失败变得越来越困难。

A test can fail intermittently if its timeout is too close to the time the tested behavior normally takes to run, or if it doesn’t synchronize correctly with the system. On a small system, an occasional flickering test might not cause problems—the test will most likely pass during the next build—but it’s risky. As the test suite grows, it becomes increasingly difficult to get a test run in which none of the flickering tests fail.

闪烁测试可能会掩盖真正的缺陷。如果系统本身偶尔出现故障,那么准确检测出这些故障的测试似乎会出现闪烁。如果套件包含不可靠的测试,那么可靠测试检测到的间歇性故障很容易被忽略。在忽略闪烁测试之前,我们需要确保了解真正的问题是什么。

Flickering tests can mask real defects. If the system itself occasionally fails, the tests that accurately detect those failures will seem to be flickering. If the suite contains unreliable tests, intermittent failures detected by reliable tests can easily be ignored. We need to make sure we understand what the real problem is before we ignore flickering tests.

允许闪烁测试对团队不利。它破坏了质量文化,即事情应该“正常工作”,即使是几个闪烁测试也会让团队不再关注损坏的构建。它还会破坏反馈的习惯。我们应该注意测试闪烁的原因,以及这是否意味着我们应该改进测试和代码的设计。当然,有时我们不得不妥协并决定暂时接受闪烁测试,但这应该是勉强的,并包括何时修复它的计划。

Allowing flickering tests is bad for the team. It breaks the culture of quality where things should “just work,” and even a few flickering tests can make a team stop paying attention to broken builds. It also breaks the habit of feedback. We should be paying attention to why the tests are flickering and whether that means we should improve the design of both the tests and code. Of course, there might be times when we have to compromise and decide to live with a flickering test for the moment, but this should be done reluctantly and include a plan for when it will be fixed.

正如我们在上一章中看到的,通过简单地让每个测试等待固定时间来实现同步是不切实际的。任何规模的系统的测试套件运行时间都太长了。我们知道我们必须等待失败的测试超时,但后续测试应该能够在代码响应后立即完成。

As we saw in the last chapter, synchronizing by simply making each test wait for a fixed time is not practical. The test suite for a system of any size will take too long to run. We know we’ll have to wait for failing tests to time out, but succeeding tests should be able to finish as soon as there’s a response from the code.

快速成功

Succeed Fast

图像

使异步测试尽快检测成功,以便提供快速反馈。

Make asynchronous tests detect success as quickly as possible so that they provide rapid feedback.

在上一节中我们概述的两种观察策略中,监听事件是最快的。测试线程可以阻塞,等待来自系统的事件。它会在收到事件后立即唤醒并检查结果。

Of the two observation strategies we outlined in the previous section, listening for events is the quickest. The test thread can block, waiting for an event from the system. It will wake up and check the result as soon as it receives an event.

另一种方法是采样,即反复轮询目标系统以查看状态变化,轮询之间会有短暂的延迟。轮询的频率必须根据测试系统进行调整,以平衡快速响应的需求和对目标系统施加的负载。在最坏的情况下,快速轮询可能会使系统变慢,从而使测试变得不可靠。

The alternative—sampling—means repeatedly polling the target system for a state change, with a short delay between polls. The frequency of this polling has to be tuned to the system under test, to balance the need for a fast response against the load it imposes on the target system. In the worst case, fast polling might slow the system enough to make the tests unreliable.

将超时值放在一个位置

Put the Timeout Values in One Place

图像

两种观察策略都使用超时来检测系统是否发生故障。同样,需要在超时太短(这会使测试不可靠)和超时太长(这会使失败的测试太慢)之间取得平衡。这种平衡在不同环境中可能有所不同,并且会随着系统随时间的增长而变化。

Both observation strategies use a timeout to detect that the system has failed. Again, there’s a balance to be struck between a timeout that’s too short, which will make the tests unreliable, and one that’s too long, which will make failing tests too slow. This balance can be different in different environments, and will change as the system grows over time.

当超时时间在一个地方定义时,很容易找到和更改。随着系统的发展,团队可以调整其值以找到速度和可靠性之间的适当平衡。

When the timeout duration is defined in one place, it’s easy to find and change. The team can adjust its value to find the right balance between speed and reliability as the system develops.

两种实现方式

Two Implementations

在测试中分散临时的休眠和超时会使它们难以理解,因为它在测试本身中留下了太多的实现细节。同步和断言正是适合分解为下级对象的行为,因为如果我们不这样做,它通常会变成一个糟糕的重复案例。这也是我们希望一次就搞定而不必再次更改的棘手代码。

Scattering ad hoc sleeps and timeouts throughout the tests makes them difficult to understand, because it leaves too much implementation detail in the tests themselves. Synchronization and assertion is just the sort of behavior that’s suitable for factoring out into subordinate objects because it usually turns into a bad case of duplication if we don’t. It’s also just the sort of tricky code that we want to get right once and not have to change again.

在本节中,我们将展示每个观察策略的示例实现。

In this section, we’ll show an example implementation of each observation strategy.

捕获通知

Capturing Notifications

基于事件的断言通过阻塞监视器来等待事件,直到收到通知或超时。当监视器收到通知时,测试线程会唤醒并继续执行(如果它发现预期的事件已经到达),否则会再次阻塞。如果测试超时,则会引发失败。

An event-based assertion waits for an event by blocking on a monitor until it gets notified or times out. When the monitor is notified, the test thread wakes up and continues if it finds that the expected event has arrived, or blocks again. If the test times out, then it raises a failure.

NotificationTrace是如何记录和测试系统发送的通知的示例。测试的设置将安排测试代码append()在事件发生时调用,例如通过插入事件侦听器,该侦听器将在触发时调用该方法。在测试主体中,测试线程调用containsNotification()以等待预期的通知,如果超时则失败。例如:

NotificationTrace is an example of how to record and test notifications sent by the system. The setup of the test will arrange for the tested code to call append() when the event happens, for example by plugging in an event listener that will call the method when triggered. In the body of the test, the test thread calls containsNotification() to wait for the expected notification or fail if it times out. For example:

跟踪.包含通知(startsWith(“WANTED”));

trace.containsNotification(startsWith("WANTED"));

将等待以 开头的通知字符串WANTED

will wait for a notification string that starts with WANTED.

在 中NotificationTrace,传入的通知存储在列表 中trace,该列表受锁 保护traceLock。该类是通用的,因此我们不指定这些通知的类型,只是说我们传入的匹配器containsNotification()必须与该类型兼容。实现使用TimeoutNotificationStream类,我们将在后面描述。

Within NotificationTrace, incoming notifications are stored in a list trace, which is protected by a lock traceLock. The class is generic, so we don’t specify the type of these notifications, except to say that the matchers we pass into containsNotification() must be compatible with that type. The implementation uses Timeout and NotificationStream classes that we’ll describe later.

public class NotificationTrace<T> {

private final Object traceLock = new Object();

private final List<T> trace = new ArrayList<T>(); 图像

private long timeoutMs;

// 构造函数和访问器来配置超时 [...]



public void append(T message) { 图像

synchronized ( traceLock ) {

trace.add(message);

traceLock .notifyAll();

}

}



public void containsNotification(Matcher<? super T>criteria) 图像

throws InterruptedException

{

Timeout timeout = new Timeout(timeoutMs);



synchronized ( traceLock ) {

NotificationStream<T> stream = new NotificationStream<T>(trace,criteria);



while (! stream.hasMatched()) {

if (timeout.hasTimedOut()) {

throw new AssertionError(failureDescriptionFrom(criteria));

}

timeout.waitOn( traceLock );

}

}

}



private String FailureDescriptionFrom(Matcher<? super T> matcher) { [...]

// 构造一个关于为什么没有匹配的描述,

// 包括匹配器和所有收到的消息。

}

public class NotificationTrace<T> {

private final Object traceLock = new Object();

private final List<T> trace = new ArrayList<T>();

private long timeoutMs;

// constructors and accessors to configure the timeout [...]



public void append(T message) {

synchronized (traceLock) {

trace.add(message);

traceLock.notifyAll();

}

}



public void containsNotification(Matcher<? super T> criteria)

throws InterruptedException

{

Timeout timeout = new Timeout(timeoutMs);



synchronized (traceLock) {

NotificationStream<T> stream = new NotificationStream<T>(trace, criteria);



while (! stream.hasMatched()) {

if (timeout.hasTimedOut()) {

throw new AssertionError(failureDescriptionFrom(criteria));

}

timeout.waitOn(traceLock);

}

}

}



private String failureDescriptionFrom(Matcher<? super T> matcher) { [...]

// Construct a description of why there was no match,

// including the matcher and all the received messages.

}

图像我们将通知存储在列表中,以便我们可以将其用于其他查询,并且如果测试失败,我们可以将它们包含在描述中(我们不显示描述的构造方式)。

We store notifications in a list so that they’re available to us for other queries and so that we can include them in a description if the test fails (we don’t show how the description is constructed).

图像append()方法由工作线程调用,会将新通知附加到跟踪中,然后通知所有等待的线程traceLock唤醒,因为发生了变化。当系统中的事件触发时,测试基础架构会调用该方法。

The append() method, called from a worker thread, appends a new notification to the trace, and then tells any threads waiting on traceLock to wake up because there’s been a change. This is called by the test infrastructure when triggered by an event in the system.

图像containsNotification()方法由测试线程调用,搜索迄今为止收到的所有通知。如果找到符合给定条件的通知,则返回。否则,它会等待更多通知到达并再次检查。如果等待超时,则测试失败。

The containsNotification() method, called from the test thread, searches through all the notifications it has received so far. If it finds a notification that matches the given criteria, it returns. Otherwise, it waits until more notifications arrive and checks again. If it times out while waiting, then it fails the test.

嵌套NotificationStream类在其列表中搜索未检查的元素,以查找符合给定条件的元素。它允许列表在调用之间增长,hasMatched()并在它查看的最后一个元素之后进行拾取。

The nested NotificationStream class searches the unexamined elements in its list for one that matches the given criteria. It allows the list to grow between calls to hasMatched() and picks up after the last element it looked at.

私有静态类 NotificationStream<N> {

私有最终 List<N> 通知;

私有最终 Matcher<? super N> 标准;

私有 int next = 0;



公共 NotificationStream(List<N> 通知, Matcher<? super N> 标准) {

this.notifications = 通知;

this.criteria = 标准;

}



公共布尔 hasMatched() {

while (next < Notifications.size()) {

if (criteria.matches(notifications.get(next)))

返回 true;

next++;

}

返回 false;

}

}

private static class NotificationStream<N> {

private final List<N> notifications;

private final Matcher<? super N> criteria;

private int next = 0;



public NotificationStream(List<N> notifications, Matcher<? super N> criteria) {

this.notifications = notifications;

this.criteria = criteria;

}



public boolean hasMatched() {

while (next < notifications.size()) {

if (criteria.matches(notifications.get(next)))

return true;

next++;

}

return false;

}

}

NotificationTrace是测试线程和工作线程之间简单协调类的一个示例。它使用了一种简单的方法,但它确实避免了可能的竞争条件,即后台线程在测试线程开始等待之前发出通知。例如,另一种实现可能containsNotification()只在前一次调用后收到搜索消息。什么是合适的取决于测试的上下文。

NotificationTrace is one example of a simple coordination class between test and worker threads. It uses a simple approach, although it does avoid a possible race condition where a background thread delivers a notification before the test thread has started waiting. Another implementation, for example, might have containsNotification() only search messages received after the previous call. What is appropriate depends on the context of the test.

轮询变更

Polling for Changes

基于样本的断言通过“探测器”反复采样系统的某些可见效果,等待探测器检测到系统已进入预期状态。采样过程有两个方面:轮询系统和故障报告,以及探测系统是否处于给定状态。将两者分开有助于我们清楚地思考行为,不同的测试可以使用不同的探测器重复使用轮询。

A sample-based assertion repeatedly samples some visible effect of the system through a “probe,” waiting for the probe to detect that the system has entered an expected state. There are two aspects to the process of sampling: polling the system and failure reporting, and probing the system for a given state. Separating the two helps us think clearly about the behavior, and different tests can reuse the polling with different probes.

Poller是如何轮询系统的示例。它会反复调用探测器,每次采样之间会有短暂的延迟,直到系统准备就绪或轮询器超时。轮询器会驱动一个探测器,该探测器实际上会检查目标系统,我们已将其抽象为一个Probe接口。

Poller is an example of how to poll a system. It repeatedly calls its probe, with a short delay between samples, until the system is ready or the poller times out. The poller drives a probe that actually checks the target system, which we’ve abstracted behind a Probe interface.

公共接口 Probe {

boolean isSatisfied();

void sample();

void describeFailureTo(Description d);

}

public interface Probe {

boolean isSatisfied();

void sample();

void describeFailureTo(Description d);

}

探测器的sample()方法会获取测试感兴趣的系统状态的快照。isSatisfied()如果该状态符合测试的验收标准,则该方法返回 true。为了简化轮询器逻辑,我们允许isSatisfied()在之前调用sample()

The probe’s sample() method takes a snapshot of the system state that the test is interested in. The isSatisfied() method returns true if that state meets the test’s acceptance criteria. To simplify the poller logic, we allow isSatisfied() to be called before sample().

public class Poller {

private long timeoutMillis;

private long pollDelayMillis;

// 构造函数和访问器来配置超时 [...]



public void check(Probe detector) throws InterruptedException {

Timeout timeout = new Timeout(timeoutMillis);



while (!probe.isSatisfied()) {

if (timeout.hasTimedOut()) {

throw new AssertionError(describeFailureOf(probe));

}

Thread.sleep(pollDelayMillis);



detector.sample();

}

}

private String describeFailureOf(Probe detector) { [...]

}

public class Poller {

private long timeoutMillis;

private long pollDelayMillis;

// constructors and accessors to configure the timeout [...]



public void check(Probe probe) throws InterruptedException {

Timeout timeout = new Timeout(timeoutMillis);



while (! probe.isSatisfied()) {

if (timeout.hasTimedOut()) {

throw new AssertionError(describeFailureOf(probe));

}

Thread.sleep(pollDelayMillis);



probe.sample();

}

}

private String describeFailureOf(Probe probe) {[...]

}

这个简单的实现将与系统的同步委托给探测器。更复杂的版本可能会在轮询器中实现同步,因此它可以在探测器之间共享。两者的相似性NotificationTrace显而易见,我们可以提取出一个通用的抽象结构,但我们希望暂时保持设计清晰。

This simple implementation delegates synchronization with the system to the probe. A more sophisticated version might implement synchronization in the poller, so it could be shared between probes. The similarity to NotificationTrace is obvious, and we could have pulled out a common abstract structure, but we wanted to keep the designs clear for now.

例如,为了轮询文件的长度,我们可以在测试中写入以下行:

To poll, for example, for the length of a file, we would write this line in a test:

断言最终(fileLength("data.txt", is(greaterThan(2000))));

assertEventually(fileLength("data.txt", is(greaterThan(2000))));

这将以更具表现力的断言形式总结我们的采样代码的构造。实现此功能的辅助方法是:

This wraps up the construction of our sampling code in a more expressive assertion. The helper methods to implement this are:

公共静态 void assertEventually(Probe detector) 抛出 InterruptedException {

new Poller(1000L, 100L).check(probe);

}



公共静态 Probe fileLength(String path, final Matcher<Integer> matcher) {

final File file = new File(path);

返回新的 Probe() {

private long lastFileLength = NOT_SET;



public void sample() { lastFileLength = file.length(); }

public boolean isSatisfied() {

return lastFileLength != NOT_SET && matcher.matches(lastFileLength);

}

public void describeFailureTo(Description d) {

d.appendText("length was ").appendValue(lastFileLength);

}

};

}

public static void assertEventually(Probe probe) throws InterruptedException {

new Poller(1000L, 100L).check(probe);

}



public static Probe fileLength(String path, final Matcher<Integer> matcher) {

final File file = new File(path);

return new Probe() {

private long lastFileLength = NOT_SET;



public void sample() { lastFileLength = file.length(); }

public boolean isSatisfied() {

return lastFileLength != NOT_SET && matcher.matches(lastFileLength);

}

public void describeFailureTo(Description d) {

d.appendText("length was ").appendValue(lastFileLength);

}

};

}

将取样行为与检查样品是否令人满意分开,使得探针的结构更加清晰。如果出现故障,我们可以保留样品结果以报告我们发现的不满意结果。

Separating the act of sampling from checking whether the sample is satisfactory makes the structure of the probe clearer. We can hold on to the sample result to report the unsatisfactory result we found if there’s a failure.

超时

Timing Out

最后我们展示Timeout两个示例断言类使用的类。它打包了时间检查和同步:

Finally we show the Timeout class that the two example assertion classes use. It packages up time checking and synchronization:

公共类 Timeout {

私有最终长 endTime;



公共 Timeout(长持续时间) {

this.endTime = System.currentTimeMillis() + 持续时间;

}



公共布尔 hasTimedOut() { 返回 timeRemaining() <= 0; }



公共 void waitOn(Object lock) 抛出 InterruptedException {

长 waitTime = timeRemaining();

如果 (waitTime > 0) lock.wait(waitTime);

}



私有长 timeRemaining() { 返回 endTime - System.currentTimeMillis(); }

}

public class Timeout {

private final long endTime;



public Timeout(long duration) {

this.endTime = System.currentTimeMillis() + duration;

}



public boolean hasTimedOut() { return timeRemaining() <= 0; }



public void waitOn(Object lock) throws InterruptedException {

long waitTime = timeRemaining();

if (waitTime > 0) lock.wait(waitTime);

}



private long timeRemaining() { return endTime - System.currentTimeMillis(); }

}

改装探头

Retrofitting a Probe

现在我们可以重写引言中的测试。测试不再对股票的当前持有量做出断言,而是必须等待股票的持有量在可接受的时间限制内达到预期水平。

We can now rewrite the test from the introduction. Instead of making an assertion about the current holding of a stock, the test must wait for the holding of the stock to reach the expected level within an acceptable time limit.

@Test public void buyAndSellOfSameStockOnSameDayCancelsOutOurHolding() {

Date tradeDate = new Date();



发送(aTradeEvent()。ofType(BUY)。onDate(tradeDate)。forStock(“A”)。withQuantity(10));

发送(aTradeEvent()。ofType(SELL)。onDate(tradeDate)。forStock(“A”)。withQuantity(10));



assertEventually (holdingOfStock(“A”,tradeDate,equalTo(0)));

}

@Test public void buyAndSellOfSameStockOnSameDayCancelsOutOurHolding() {

Date tradeDate = new Date();



send(aTradeEvent().ofType(BUY).onDate(tradeDate).forStock("A").withQuantity(10));

send(aTradeEvent().ofType(SELL).onDate(tradeDate).forStock("A").withQuantity(10));



assertEventually(holdingOfStock("A", tradeDate, equalTo(0)));

}

以前,该holdingOfStock()方法返回一个要比较的值。现在,它返回一个Probe对系统持有量进行采样的 ,并返回它是否符合 Hamcrest 匹配器定义的验收标准(在本例中为 )equalTo(0)

Previously, the holdingOfStock() method returned a value to be compared. Now it returns a Probe that samples the system’s holding and returns if it meets the acceptance criteria defined by a Hamcrest matcher—in this case equalTo(0).

失控测试

Runaway Tests

不幸的是,尽管我们现在正在抽样以获得结果,但新版本的测试仍然不可靠。断言正在等待持有量变为零,这是我们开始时的状态,因此在系统开始处理之前,测试就有可能通过。此测试可以在系统之前运行,而无需实际测试任何内容。

Unfortunately, the new version of the test is still unreliable, even though we’re now sampling for a result. The assertion is waiting for the holding to become zero, which is what we started out with, so it’s possible for the test to pass before the system has even begun processing. This test can run ahead of the system without actually testing anything.

失控测试最糟糕的方面是它们会给出假阳性结果,因此损坏的代码看起来好像是可以运行的。我们通常不会检查通过的测试,因此很容易忽略这种故障,直到出现问题。更棘手的是,代码可能在我们第一次编写时可以运行,因为测试在开发过程中恰好同步正确,但现在它坏了,我们无法分辨。

The worst aspect of runaway tests is that they give false positive results, so broken code looks like it’s working. We don’t often review tests that pass, so it’s easy to miss this kind of failure until something breaks down the line. Even more tricky, the code might have worked when we first wrote it, as the tests happened to synchronize correctly during development, but now it’s broken and we can’t tell.

谨防让系统返回到原状态的测试

Beware of Tests That Return the System to the Same State

图像

当异步测试断言系统返回到先前状态时,请务必小心。除非它还断言系统在断言初始状态之前进入中间状态,否则测试将在系统之前运行。

Be careful when an asynchronous test asserts that the system returns to a previous state. Unless it also asserts that the system enters an intermediate state before asserting the initial state, the test will run ahead of the system.

为了阻止测试在系统运行之前运行,我们必须添加等待系统进入中间状态的断言。例如,我们确保第一个交易事件已被处理,然后再断言第二个事件的影响:

To stop the test running ahead of the system, we must add assertions that wait for the system to enter an intermediate state. Here, for example, we make sure that the first trade event has been processed before asserting the effect of the second event:

@Test public void buyAndSellOfSameStockOnSameDayCancelsOutOurHolding() {

Date tradeDate = new Date();



发送(aTradeEvent()。ofType(BUY)。onDate(tradeDate)。forStock(“A”)。withQuantity(10));

断言事件(holdingOfStock(“A”,tradeDate,equalTo(10)));



发送(aTradeEvent()。ofType(SELL)。onDate(tradeDate)。forStock(“A”)。withQuantity(10));

断言事件(holdingOfStock(“A”,tradeDate,equalTo(0)));

}

@Test public void buyAndSellOfSameStockOnSameDayCancelsOutOurHolding() {

Date tradeDate = new Date();



send(aTradeEvent().ofType(BUY).onDate(tradeDate).forStock("A").withQuantity(10));

assertEventually(holdingOfStock("A", tradeDate, equalTo(10)));



send(aTradeEvent().ofType(SELL).onDate(tradeDate).forStock("A").withQuantity(10));

assertEventually(holdingOfStock("A", tradeDate, equalTo(0)));

}

类似地,在第 14 章中,我们检查了 Auction Sniper 用户界面的验收测试中的所有显示状态:

Similarly, in Chapter 14, we check all the displayed states in the acceptance tests for the Auction Sniper user interface:

拍卖.reportPrice(1098,97,ApplicationRunner.SNIPER_XMPP_ID);

应用程序.hasShownSniperIsWinning();

拍卖.announceClosed();

应用程序.hasShownSniperHasWon();

auction.reportPrice(1098, 97, ApplicationRunner.SNIPER_XMPP_ID);

application.hasShownSniperIsWinning();

auction.announceClosed();

application.hasShownSniperHasWon();

我们要确保狙击手在继续发送下一条消息之前已经对每条消息做出了回应。

We want to make sure that the sniper has responded to each message before continuing on to the next one.

丢失更新

Lost Updates

采样测试和监听事件的测试之间的一个显著区别是,轮询可能会错过后来被覆盖的状态变化,如图 27.1 所示

A significant difference between tests that sample and those that listen for events is that polling can miss state changes that are later overwritten, Figure 27.1.

图 27.1 轮询测试可能会错过被测系统中的变化

Figure 27.1 A test that polls can miss changes in the system under test

图像

如果测试可以记录来自系统的通知,则可以查看其记录以查找重要的通知。

If the test can record notifications from the system, it can look through its records to find significant notifications.

图27.2 记录通知不会丢失更新的测试

Figure 27.2 A test that records notifications will not lose updates

图像

为了保证可靠性,采样测试必须确保其系统在触发任何进一步交互之前处于稳定状态。采样测试需要构建为一系列阶段,如图 27.3所示。在每个阶段,测试都会发送刺激以促使系统可观察状态发生变化,然后等待该变化变得可见或超时。

To be reliable, a sampling test must make sure that its system is stable before triggering any further interactions. Sampling tests need to be structured as a series of phases, as shown in Figure 27.3. In each phase, the test sends a stimulus to prompt a change in the observable state of the system, and then waits until that change becomes visible or times out.

图 27.3 抽样测试的阶段

Figure 27.3 Phases of a sampling test

图像

这表明了采样测试的精确度的极限。在“刺激”和“采样”之间,测试所能做的就是等待。我们可以编写更可靠的测试,方法是不混淆循环中的不同步骤,并且只有在通过观察采样状态的变化检测到系统稳定后才触发进一步的更改。

This shows the limits of how precise we can be with a sampling test. All the test can do between “stimulate” and “sample” is wait. We can write more reliable tests by not confusing the different steps in the loop and only triggering further changes once we’ve detected that the system is stable by observing a change in its sampled state.

测试操作是否无效

Testing That an Action Has No Effect

异步测试会查找系统中的变化,因此要测试某些东西是否没有变化需要一点小聪明。同步测试不存在这个问题,因为它们完全控制被测代码的执行。调用目标对象后,同步测试可以查询其状态或检查它是否没有对其邻居进行任何意外调用。

Asynchronous tests look for changes in a system, so to test that something has not changed takes a little ingenuity. Synchronous tests don’t have this problem because they completely control the execution of the tested code. After invoking the target object, synchronous tests can query its state or check that it hasn’t made any unexpected calls to its neighbors.

如果异步测试等待某事不发生,它甚至不能在检查结果之前确定系统是否已启动。例如,如果我们想表明其他地区的交易不计入股票持有量,那么这个测试:

If an asynchronous test waits for something not to happen, it cannot even be sure that the system has started before it checks the result. For example, if we want to show that trades in another region are not counted in the stock holding, then this test:

@Test public void doesNotShowTradesInOtherRegions() {

发送(aTradeEvent()。ofType(BUY)。forStock("A")。withQuantity(10)

。inTradingRegion(OTHER_REGION))

断言事件(holdingOfStock("A", tradeDate, equalTo(0)));

}

@Test public void doesNotShowTradesInOtherRegions() {

send(aTradeEvent().ofType(BUY).forStock("A").withQuantity(10)

.inTradingRegion(OTHER_REGION));

assertEventually(holdingOfStock("A", tradeDate, equalTo(0)));

}

无法判断系统是否正确忽略了交易,还是尚未收到交易。最明显的解决方法是让测试等待一段固定的时间,然后检查是否没有发生不必要的事件。不幸的是,即使测试成功,这也会使测试运行缓慢,从而违反了我们的“快速成功”规则。

cannot tell whether the system has correctly ignored the trade or just not received it yet. The most obvious workaround is for the test to wait for a fixed period of time and then check that the unwanted event did not occur. Unfortunately, this makes the test run slowly even when successful, and so breaks our rule of “succeed fast.”

相反,测试应该触发可检测的行为并用它来检测系统是否已稳定。这里的技巧在于选择一种不会干扰测试断言并在测试行为之后完成的行为。例如,我们可以在区域示例中添加另一个交易事件。这表明区域外事件被排除在外,因为其数量未包含在总持有量中。

Instead, the test should trigger a behavior that is detectable and use that to detect that the system has stabilized. The skill here is in picking a behavior that will not interfere with the test’s assertions and that will complete after the tested behavior. For example, we could add another trade event to the regions example. This shows that the out-of-region event is excluded because its quantity is not included in the total holding.

@Test public void doesNotShowTradesInOtherRegions() {

发送(aTradeEvent()。ofType(BUY)。forStock("A")。withQuantity(10)

。inTradingRegion(OTHER_REGION));

发送(aTradeEvent()。ofType(BUY)。forStock("A")。withQuantity(66

。inTradingRegion(SAME_REGION));

断言事件(holdingOfStock("A", tradeDate, equalTo(66)));

}

@Test public void doesNotShowTradesInOtherRegions() {

send(aTradeEvent().ofType(BUY).forStock("A").withQuantity(10)

.inTradingRegion(OTHER_REGION));

send(aTradeEvent().ofType(BUY).forStock("A").withQuantity(66)

.inTradingRegion(SAME_REGION));

assertEventually(holdingOfStock("A", tradeDate, equalTo(66)));

}

当然,该测试假设交易事件是按顺序而不是并行处理的,因此第二个事件不能超过第一个事件并给出假阳性。这就是为什么此类测试不是完全“黑盒”而必须对系统结构做出假设的原因。这可能会使这些测试脆弱性——如果系统改变了它们所基于的假设,它们就会错误地报告。一种应对措施是添加测试来确认这些预期——在这种情况下,也许是压力测试来确认事件处理顺序,并在情况发生变化时提醒团队。也就是说,应该已经有其他测试可以确认这些假设,因此只需关联这些测试就足够了,例如将它们分组到同一个测试包中。

Of course, this test assumes that trade events are processed in sequence, not in parallel, so that the second event cannot overtake the first and give a false positive. That’s why such tests are not completely “black box” but have to make assumptions about the structure of the system. This might make these tests brittle—they would misreport if the system changes the assumptions they’ve been built on. One response is to add a test to confirm those expectations—in this case, perhaps a stress test to confirm event processing order and alert the team if circumstances change. That said, there should already be other tests that confirm those assumptions, so it may be enough just to associate these tests, for example by grouping them in the same test package.

区分同步和断言

Distinguish Synchronizations and Assertions

我们有一种机制,用于将测试与其系统同步并对该系统做出断言 — 等待可观察的条件,如果条件未发生则超时。这两项活动之间的唯一区别在于我们对它们含义的解释。与往常一样,我们希望明确我们的意图,但这在这里尤其重要,因为存在这样的风险:有人可能会在稍后查看测试并删除看似重复的断言,从而意外引入竞争条件。

We have one mechanism for synchronizing a test with its system and for making assertions about that system—wait for an observable condition and time out if it doesn’t happen. The only difference between the two activities is our interpretation of what they mean. As always, we want to make our intentions explicit, but it’s especially important here because there’s a risk that someone may look at the test later and remove what looks like a duplicate assertion, accidentally introducing a race condition.

我们经常采用命名方案来区分同步和断言。例如,我们可能有waitUntil()assertEventually()方法来表达共享底层实现的不同检查的目的。

We often adopt a naming scheme to distinguish between synchronizations and assertions. For example, we might have waitUntil() and assertEventually() methods to express the purpose of different checks that share an underlying implementation.

或者,我们可以将术语“断言”保留用于同步测试,并在异步测试中使用不同的命名约定,就像我们在拍卖狙击手示例中所做的那样。

Alternatively, we might reserve the term “assert” for synchronous tests and use a different naming conventions in asynchronous tests, as we did in the Auction Sniper example.

外部化事件源

Externalize Event Sources

有些系统会在内部触发自己的事件。最常见的例子是使用计时器来安排活动。这可能包括经常运行的重复操作,例如打包电子邮件以供转发,或几天甚至几周后运行的后续操作,例如确认交货日期。

Some systems trigger their own events internally. The most common example is using a timer to schedule activities. This might include repeated actions that run frequently, such as bundling up emails for forwarding, or follow-up actions that run days or even weeks in the future, such as confirming a delivery date.

隐藏的计时器很难使用,因为它们使得很难判断系统何时处于稳定状态以供测试做出断言。等待重复操作运行的速度太慢,无法“快速成功”,更不用说一个月后安排的操作了。我们也不希望测试因为刚刚启动的预定活动的干扰而不可预测地中断。试图通过同步计时器来测试系统太脆弱了。

Hidden timers are very difficult to work with because they make it hard to tell when the system is in a stable state for a test to make its assertions. Waiting for a repeated action to run is too slow to “succeed fast,” to say nothing of an action scheduled a month from now. We also don’t want tests to break unpredictably because of interference from a scheduled activity that’s just kicked in. Trying to test a system by coinciding timers is just too brittle.

唯一的解决方案是通过将系统与自身的调度分离来使系统具有确定性。我们可以将事件生成拉出到外部驱动的共享服务中。例如,在一个项目中,我们将系统的调度程序实现为 Web 服务。系统组件通过向调度程序发出 HTTP 请求来调度活动,调度程序通过发出 HTTP“回发”来触发活动。在另一个项目中,调度程序将通知发布到组件监听的消息总线主题上。

The only solution is to make the system deterministic by decoupling it from its own scheduling. We can pull event generation out into a shared service that is driven externally. For example, in one project we implemented the system’s scheduler as a web service. System components scheduled activities by making HTTP requests to the scheduler, which triggered activities by making HTTP “postbacks.” In another project, the scheduler published notifications onto a message bus topic that the components listened to.

有了这种分离,测试可以通过充当调度程序并确定性地生成事件来逐步了解系统的行为。现在我们可以快速可靠地运行系统测试。这是测试要求导致更好设计的一个很好的例子。我们被迫抽象出调度,这意味着我们将不会在系统中隐藏多个实现。通常,引入这样的事件基础结构对于监控和管理很有用。

With this separation in place, tests can step the system through its behavior by posing as the scheduler and generating events deterministically. Now we can run system tests quickly and reliably. This is a nice example of a testing requirement leading to a better design. We’ve been forced to abstract out scheduling, which means we won’t have multiple implementations hidden in the system. Usually, introducing such an event infrastructure turns out to be useful for monitoring and administration.

当然,这也是一种权衡。我们的测试不再运行整个系统。我们优先考虑测试速度和可靠性,而不是保真度。我们通过保持调度程序的 API 尽可能简单并对其进行严格测试(另一个优势)来弥补这一点。我们可能还会编写一些运行速度较慢的测试,在单独的版本中运行,这些测试将整个系统(包括实际调度程序)一起运行。

There’s a trade-off too, of course. Our tests are no longer exercising the entire system. We’ve prioritized test speed and reliability over fidelity. We compensate by keeping the scheduler’s API as simple as possible and testing it rigorously (another advantage). We would probably also write a few slow tests, running in a separate build, that exercise the whole system together including the real scheduler.

后记。Mock 对象简史

Afterword. A Brief History of Mock Objects

蒂姆·麦金农

Tim Mackinnon

介绍

Introduction

模拟对象背后的想法和概念并非一朝一夕就能实现的。许多不同的开发人员在实验、讨论和协作方面有着悠久的历史,他们将一个想法的种子培育成更深刻的东西。最终的结果——本书的主题——应该会对您的软件开发有所帮助;但《模拟对象的制作》的背景故事也很有趣——也证明了参与其中的人们的奉献精神。我希望重温这段历史也能激励你挑战自己对可能性的想法,并尝试新的做法。

The ideas and concepts behind mock objects didn’t materialise in a single day. There’s a long history of experimentation, discussion, and collaboration between many different developers who have taken the seed of an idea and grown it into something more profound. The final result—the topic of this book—should help you with your software development; but the background story of “The Making of Mock Objects” is also interesting—and a testament to the dedication of the people involved. I hope revisiting this history will inspire you too to challenge your thoughts on what is possible and to experiment with new practices.

起源

Origins

故事开始于一个环形交叉路口1999 年末,伦敦 Archway 站附近。那天晚上,伦敦一家软件架构小组的几名成员2开会讨论软件中的热门问题。讨论转向了敏捷软件开发的经验,我提到了编写测试似乎对我们的代码产生了影响。这是在第一本《极限编程》出版之前,像我们这样的团队仍在探索如何进行测试驱动开发——包括什么构成了一个好的测试。特别是,我注意到一种趋势是将“getter”方法添加到我们的对象中以方便测试。这感觉不对,因为它可能被视为违反面向对象原则,所以我对其他成员的想法很感兴趣。谈话非常热烈——主要集中在测试中的实用主义和纯面向对象设计之间的紧张关系上。我们最近还有一位同事的例子,Oli Bye,存根 Java Servlet API,用于在没有服务器的情况下测试 Web 应用程序。

The story began on a roundabout1 near Archway station in London in late 1999. That evening, several members of a London-based software architecture group2 met to discuss topical issues in software. The discussion turned to experiences with Agile Software Development and I mentioned the impact that writing tests seemed to be having on our code. This was before the first Extreme Programming book had been published, and teams like ours were still exploring how to do test-driven development—including what constituted a good test. In particular, I had noticed a tendency to add “getter” methods to our objects to facilitate testing. This felt wrong, since it could be seen as violating object-oriented principles, so I was interested in the thoughts of the other members. The conversation was quite lively—mainly centering on the tension between pragmatism in testing and pure object-oriented design. We also had a recent example of a colleague, Oli Bye, stubbing out the Java Servlet API for testing a web application without a server.

1. “Roundabout” 是英国人对环形交叉路口的意思。

1. “Roundabout” is the UK term for a traffic circle.

2.这次的嘉宾是 Tim Mackinnon、Peter Marks、Ivan Moore 和 John Nolan。

2. On this occasion, they were Tim Mackinnon, Peter Marks, Ivan Moore, and John Nolan.

我特别记得那天晚上的一张洋葱的粗略图表3及其对软件多层的隐喻,以及“没有 Getters!就这样!”的口头禅。讨论围绕着如何安全地剥开洋葱并测试其层而不影响其设计展开。解决方案是专注于软件组件的组成(该小组之前曾多次讨论过 Brad Cox 关于软件组件的想法)。这是一次有趣的意见碰撞,对组合的强调(现在称为依赖注入)为我们提供了一种消除 getters 的技术,我们“务实地”将其添加到对象中,以便我们可以为它们编写测试。

I particularly remember from that evening a crude diagram of an onion3 and its metaphor of the many layers of software, along with the mantra “No Getters! Period!” The discussion revolved around how to safely peel back and test layers of that onion without impacting its design. The solution was to focus on the composition of software components (the group had discussed Brad Cox’s ideas on software components many times before). It was an interesting collision of opinions, and the emphasis on composition—now referred to as dependency injection—gave us a technique for eliminating the getters we were “pragmatically” adding to objects so we could write tests for them.

3.最初由约翰·诺兰 (John Nolan) 绘制。

3. Initially drawn by John Nolan.

第二天,我们在 Connextra 的小团队4开始将这个想法付诸实践。我们从代码的各个部分中删除了 getter,并使用了一种组合策略,即添加构造函数,该构造函数将我们想要通过 getter 测试的对象作为参数。起初,这感觉很麻烦,我们最近招收的两名毕业生并不相信。然而,我有 Smalltalk 背景,所以对我来说,组合和委托的想法感觉很正确。强制执行“无 getter”规则似乎是在我们使用的 Java 语言中实现更面向对象感觉的一种方式。

The following day, our small team at Connextra4 started putting the idea into practice. We removed the getters from sections of our code and used a compositional strategy by adding constructors that took the objects we wanted to test via getters as parameters. At first this felt cumbersome, and our two recent graduate recruits were not convinced. I, however, had a Smalltalk background, so to me the idea of composition and delegation felt right. Enforcing a “no getters” rule seemed like a way to achieve a more object-oriented feeling in the Java language we were using.

4.该团队由 Tim Mackinnon、Tung Mac 和 Matthew Cooke 组成,由 Peter Marks 和 John Nolan 负责指导。Connextra 现在是 Bet Genius 的一部分。

4. The team consisted of Tim Mackinnon, Tung Mac, and Matthew Cooke, with direction from Peter Marks and John Nolan. Connextra is now part of Bet Genius.

我们坚持了几天,开始发现一些模式。我们更多的对话是关于期望对象之间发生的事情,并且我们经常在注入的对象中使用名称为expectedURL和的变量expectedServiceName。另一方面,当我们的测试失败时,我们厌倦了在调试器中逐步查看出了什么问题。我们开始添加名称为和的变量actualURLactualServiceName以允许注入的测试对象抛出带有有用消息的异常。并排打印预期值和实际值可以立即向我们显示问题所在。

We stuck to it for several days and started to see some patterns emerging. More of our conversations were about expecting things to happen between our objects, and we frequently had variables with names like expectedURL and expectedServiceName in our injected objects. On the other hand, when our tests failed we were tired of stepping through in a debugger to see what went wrong. We started adding variables with names like actualURL and actualServiceName to allow the injected test objects to throw exceptions with helpful messages. Printing the expected and actual values side-by-side showed us immediately what the problem was.

经过几周的时间,我们将这些想法重构为一组类:ExpectationValue用于单个值、ExpectationList用于按特定顺序排列的多个值以及ExpectationSet用于按任何顺序排列的唯一值。后来,Tung Mac 还添加了ExpectationCounter用于我们不想指定显式值而只想计算调用次数的情况。开始感觉好像发生了一些有趣的事情,但对我来说这似乎太明显了,以至于没有太多可描述的。一天下午,Peter Marks 决定我们应该为我们正在做的事情想出一个名字——这样我们至少可以打包代码——经过一些建议后,他提出了“mock”。我们可以将它用作名词和动词,而且它可以很好地重构到我们的代码中,所以我们采用了它。

Over the course of several weeks we refactored these ideas into a group of classes: ExpectationValue for single values, ExpectationList for multiple values in a particular order, and ExpectationSet for unique values in any order. Later, Tung Mac also added ExpectationCounter for situations where we didn’t want to specify explicit values but just count the number of calls. It started to feel as if something interesting was happening, but it seemed so obvious to me that there wasn’t really much to describe. One afternoon, Peter Marks decided that we should come up with name for what we were doing—so we could at least package the code—and, after a few suggestions, proposed “mock.” We could use it both as a noun and a verb, and it refactored nicely into our code, so we adopted it.

传播信息

Spreading the Word

大约在这个时候,我们5还创办了伦敦极限星期二俱乐部 (XTC),与其他团队分享极限编程的经验。在一次会议上,我描述了我们的重构实验,并解释说我觉得它帮助我们的初级开发人员编写更好的面向对象代码。我以“但这是一种显而易见的技术,我相信大多数人最终都会这样做”结束了这个故事。史蒂夫指出,最明显的事情并不总是那么明显,通常很难描述。他认为如果我们能从树木中筛选出木材,这可以写一篇很棒的论文,所以我们决定与另一位 XTC 成员 (Philip Craig) 合作,为 XP2000 会议写一些东西。如果没有其他事情,我们想去撒丁岛。

Around this time, we5 also started the London Extreme Tuesday Club (XTC) to share experiences of Extreme Programming with other teams. During one meeting, I described our refactoring experiments and explained that I felt that it helped our junior developers write better object-oriented code. I finished the story by saying, “But this is such an obvious technique that I’m sure most people do it eventually anyway.” Steve pointed out that the most obvious things aren’t always so obvious and are usually difficult to describe. He thought this could make a great paper if we could sort the wood from the trees, so we decided to collaborate with another XTC member (Philip Craig) and write something for the XP2000 conference. If nothing else, we wanted to go to Sardinia.

5.与 Tim Mackinnon、Oli Bye、Paul Simmons 和 Steve Freeman 合作。Oli 创造了 XTC 这个名字。

5. With Tim Mackinnon, Oli Bye, Paul Simmons, and Steve Freeman. Oli coined the name XTC.

我们开始分析这些想法,并给它们起一组一致的名称,研究真实的代码示例以了解该技术的本质。我们将发现的新概念移植到原始的 Connextra 代码库中,以验证其有效性。这是一个激动人心的时刻,我记得我们花了很多个晚上来完善我们的想法——尽管我们仍在努力为模拟对象想出一个准确的“电梯游说”。我们知道使用它们来驱动出色的代码是什么感觉,但向不属于 XTC 的其他开发人员描述这种体验仍然具有挑战性。

We began to pick apart the ideas and give them a consistent set of names, studying real code examples to understand the essence of the technique. We backported new concepts we discovered to the original Connextra codebase to validate their effectiveness. This was an exciting time and I recall that it took many late nights to refine our ideas—although we were still struggling to come up with an accurate “elevator pitch” for mock objects. We knew what it felt like when using them to drive great code, but describing this experience to other developers who weren’t part of the XTC was still challenging.

XP2000 论文[Mackinnon00]和最初的模拟对象库的反响褒贬不一 — 对于某些人来说,这是革命性的,而对于另一些人来说,这是不必要的开销。回想起来,当我们开始时,Java 并没有很好的反射,这意味着许多步骤都是手动的,或者使用代码生成工具进行增强。6这让人们失望了——他们无法将想法与实施分开。

The XP2000 paper [Mackinnon00] and the initial mock objects library had a mixed reception—for some it was revolutionary, for others it was unnecessary overhead. In retrospect, the fact that Java didn’t have good reflection when we started meant that many of the steps were manual, or augmented with code generation tools.6 This turned people off—they couldn’t separate the idea from the implementation.

6.后来,随着 Java 1.1 的发布,这种情况发生了变化,它改进了反射,并且其他读过我们论文的人编写了更多的工具,比如 Tammo Freese 的 Easymock。

6. This later changed as Java 1.1 was released, which improved reflection, and as others who had read our paper wrote more tools, such as Tammo Freese’s Easymock.

另一代

Another Generation

故事还在继续,Nat Pryce 采纳了这些想法,并用 Ruby 实现了它们。他利用 Ruby 的反射功能将期望直接作为块写入测试。受其博士论文中关于组件间协议的研究的影响,他的库将重点从断言参数值转变为断言对象之间发送的消息。Nat 随后将他的实现移植到 Java,使用ProxyJava 1.3 中的新类型,并使用“约束”对象定义期望。当 Nat 向我们展示这项工作时,我们立即就明白了。他将他的库捐赠给了模拟对象项目,并参观了 Connextra 办公室,我们在那里一起合作添加了 Connextra 开发人员需要的功能。

The story continues when Nat Pryce took the ideas and implemented them in Ruby. He exploited Ruby’s reflection to write expectations directly into the test as blocks. Influenced by his PhD work on protocols between components, his library changed the emphasis from asserting parameter values to asserting messages sent between objects. Nat then ported his implementation to Java, using the new Proxy type in Java 1.3 and defining expectations with “constraint” objects. When Nat showed us this work, it immediately clicked. He donated his library to the mock objects project and visited the Connextra offices where we worked together to add features that the Connextra developers needed.

在 Nat 的办公室里,模拟对象被频繁使用,我们被迫使用他的改进来提供更具描述性的失败消息。我们曾看到,当测试失败的原因不够明显时,我们的开发人员会陷入困境(后来,我们观察到,这通常暗示对象承担了太多责任)。现在,约束使我们能够编写更具表现力的测试,并提供更好的故障诊断,因为约束对象可以解释出了什么问题。7例如,约束失败stringBegins可能会产生如下消息:

With Nat in the office where mock objects were being used constantly, we were driven to use his improvements to provide more descriptive failure messages. We had seen our developers getting bogged down when the reason for a test failure was not obvious enough (later, we observed that this was often a hint that an object had too many responsibilities). Now, constraints allowed us to write tests that were more expressive and provided better failure diagnostics, as the constraint objects could explain what went wrong.7 For example, a failure on a stringBegins constraint could produce a message like:

7 . 后来,史蒂夫说服 Charlie Poole 在 NUnit 中加入约束。又花了几年时间,匹配器(约束的最新版本)才被 JUnit 采用。

7. Later, Steve talked Charlie Poole into including constraints in NUnit. It took some extra years to have matchers (the latest version of constraints) adopted by JUnit.

预期以“http”开头的字符串参数

,但调用时使用的值为“ftp.domain.com”

Expected a string parameter beginning with "http"

but was called with a value of "ftp.domain.com"

我们以 Dynamock 的名义发布了 Nat 库的新改进版本。

We released the new improved version of Nat’s library under the name Dynamock.

随着我们改进库,越来越多的程序员开始使用它,这带来了新的要求。我们开始向 API 添加越来越多的选项,直到最终变得过于复杂而难以维护——尤其是当我们必须支持多个 Java 版本时。与此同时,史蒂夫厌倦了设置期望所需的重复语法,因此他引入了一个 Smalltalk 级联版本——对同一对象的多次调用。

As we improved the library, more programmers started using it, which introduced new requirements. We started adding more and more options to the API until, eventually, it became too complicated to maintain—especially as we had to support multiple versions of Java. Meanwhile, Steve tired of the the duplication in the syntax required to set up expectations, so he introduced a version of a Smalltalk cascade—multiple calls to the same object.

然后,史蒂夫注意到,在 Java 等静态类型语言中,级联可以返回一系列接口,以控制何时向调用者提供方法 - 实际上,我们可以使用类型来编码工作流。史蒂夫还希望通过引导新一代 IDE 使用“正确”的完成选项进行提示来改善编程体验。在一年的时间里,史蒂夫和 Nat 在我们其他人的大量投入下,努力推动这个想法,开发了 jMock,这是我们原始 Dynamock 框架的富有表现力的 API。它也被移植到 C# 中作为 NMock。在此过程中的某个时候,他们意识到他们实际上是在用Java 编写一种可用于编写期望的语言;他们后来在 OOPLSA 论文[Freeman06]中写了这一点。

Then Steve noticed that in a statically typed language like Java, a cascade could return a chain of interfaces to control when methods are made available to the caller—in effect, we could use types to encode a workflow. Steve also wanted to improve the programming experience by guiding the new generation of IDEs to prompt with the “right” completion options. Over the course of a year, Steve and Nat, with much input from the rest of us, pushed the idea hard to produce jMock, an expressive API over our original Dynamock framework. This was also ported to C# as NMock. At some point in this process, they realized that they were actually writing a language in Java which could be used to write expectations; they wrote this up later in an OOPLSA paper [Freeman06].

合并

Consolidation

通过在 Connextra 和其他公司的工作经验以及多次演讲,我们提高了对模拟对象理念的理解和交流。史蒂夫(受到早期精益软件材料的启发)创造了“需求驱动开发”一词,另一位同事乔·沃尔内斯(Joe Walnes)绘制了一幅漂亮的可视化图,其中显示了相互通信的对象岛。乔还洞察到使用模拟对象来驱动对象之间接口的设计。当时,我们正在努力推广使用模拟对象作为设计工具的想法;许多人(包括一些作者)只将其视为一种加速单元测试的技术。乔用他简单的启发式方法“只拥有自己的模拟类型”打破了所有概念障碍。

Through our experience in Connextra and other companies, and through giving many presentations, we improved our understanding and communication of the ideas of mock objects. Steve (inspired by some of the early lean software material) coined the term “needs-driven development,” and Joe Walnes, another colleague, drew a nice visualisation of islands of objects communicating with each other. Joe also had the insight of using mock objects to drive the design of interfaces between objects. At the time, we were struggling to promote the idea of using mock objects as a design tool; many people (including some authors) saw it only as a technique for speeding up unit tests. Joe cut through all the conceptual barriers with his simple heuristic of “Only mock types you own.”

我们采纳了所有这些想法,并撰写了第二篇会议论文《模拟角色而非对象》[Freeman04]。我们最初的描述过于侧重于实现,而关键的想法是该技术强调对象彼此扮演的角色。当开发人员很好地使用模拟对象时,我观察到他们会绘制他们想要测试的内容的图表,或使用 CRC 卡来扮演角色关系 — 然后这些会很好地转化为驱动所需代码的模拟对象和测试。

We took all these ideas and wrote a second conference paper, “Mock Roles not Objects” [Freeman04]. Our initial description had focused too much on implementation, whereas the critical idea was that the technique emphasizes the roles that objects play for each other. When developers are using mock objects well, I observe them drawing diagrams of what they want to test, or using CRC cards to roleplay relationships—these then translate nicely into mock objects and tests that drive the required code.

从那时起,Nat 和 Steve 重新设计了 jMock 以制作 jMock2,而 Joe 则将约束提取到 Hamcrest 库中(现已被 JUnit 采用)。现在还有多种不同语言的模拟对象库可供选择。

Since then, Nat and Steve have reworked jMock to produce jMock2, and Joe has extracted constraints into the Hamcrest library (now adopted by JUnit). There’s also now a wide selection of mock object libraries, in many different languages.

结果是值得付出努力的。我想我们终于可以说,现在有一项记录良好、经过精心打磨的技术可以帮助您编写更好的软件。从那些不起眼的“无所事事”开始,这本书总结了我们所有合作者的多年经验,并加入了 Steve 和 Nat 的语言专业知识和对细节的细心关注,创造出了比各部分总和更伟大的东西。

The results have been worth the effort. I think we can finally say that there is now a well-documented and polished technique that helps you write better software. From those humble “no getters” beginnings, this book summarizes years of experience from all of us who have collaborated, and adds Steve and Nat’s language expertise and careful attention to detail to produce something that is greater than the sum of its parts.

附录 A. jMock2 备忘单

Appendix A. jMock2 Cheat Sheet

介绍

Introduction

本书始终使用jMock2作为模拟对象框架。本章总结了其功能并展示了一些使用示例。我们使用 JUnit 4.6(我们假设您熟悉它);jMock 还支持 JUnit3。完整文档可在www.jmock.org上找到。

We use jMock2 as our mock object framework throughout this book. This chapter summarizes its features and shows some examples of how to use them. We’re using JUnit 4.6 (we assume you’re familiar with it); jMock also supports JUnit3. Full documentation is available at www.jmock.org.

我们将展示 jMock 单元测试的结构并描述其功能。下面是一个完整的示例:

We’ll show the structure of a jMock unit test and describe what its features do. Here’s a whole example:

导入 org.jmock.Expectations;

导入 org.jmock.Mockery;

导入 org.jmock.integration.junit4.JMock;

导入 org.jmock.integration.junit4.JUnit4Mockery;



@RunWith(JMock.class)

public class TurtleDriverTest {

private final Mockery context = new JUnit4Mockery();

private final Turtle turtle = context.mock(Turtle.class);



@Test public void

goesAMinimumDistance() {

final Turtle turtle2 = context.mock(Turtle.class, "turtle2");

final TurtleDriver driver = new TurtleDriver(turtle1, turtle2); // 设置



context.checking(new Expectations() {{ // 期望

ignoring (turtle2);

allowing (turtle).flashLEDs();



oneOf (turtle).turn(45);

oneOf (turtle).forward(with(greaterThan(20)));

atLeast(1).of (turtle).stop();

}});



driver.goNext(45); // 调用代码

assertTrue("driver has moving", driver.hasMoved()); // 进一步断言

}

}

import org.jmock.Expectations;

import org.jmock.Mockery;

import org.jmock.integration.junit4.JMock;

import org.jmock.integration.junit4.JUnit4Mockery;



@RunWith(JMock.class)

public class TurtleDriverTest {

private final Mockery context = new JUnit4Mockery();

private final Turtle turtle = context.mock(Turtle.class);



@Test public void

goesAMinimumDistance() {

final Turtle turtle2 = context.mock(Turtle.class, "turtle2");

final TurtleDriver driver = new TurtleDriver(turtle1, turtle2); // set up



context.checking(new Expectations() {{ // expectations

ignoring (turtle2);

allowing (turtle).flashLEDs();



oneOf (turtle).turn(45);

oneOf (turtle).forward(with(greaterThan(20)));

atLeast(1).of (turtle).stop();

}});



driver.goNext(45); // call the code

assertTrue("driver has moved", driver.hasMoved()); // further assertions

}

}

测试夹具类

Test Fixture Class

首先,我们通过创建来设置测试装置类Mockery

First, we set up the test fixture class by creating its Mockery.

导入 org.jmock.Expectations;

导入 org.jmock.Mockery;

导入 org.jmock.integration.junit4.JMock;

导入 org.jmock.integration.junit4.JUnit4Mockery;



@RunWith(JMock.class)

公共类 TurtleDriverTest {

private final Mockery context = new JUnit4Mockery();

[...]

}

import org.jmock.Expectations;

import org.jmock.Mockery;

import org.jmock.integration.junit4.JMock;

import org.jmock.integration.junit4.JUnit4Mockery;



@RunWith(JMock.class)

public class TurtleDriverTest {

private final Mockery context = new JUnit4Mockery();

[...]

}

对于被测试的对象,aMockery代表其上下文— 它将与之通信的邻近对象。测试将告诉模拟对象创建模拟对象、对模拟对象设置期望,并在测试结束时检查这些期望是否得到满足。按照惯例,模拟存储在名为 的实例变量中context

For the object under test, a Mockery represents its context—the neighboring objects it will communicate with. The test will tell the mockery to create mock objects, to set expectations on the mock objects, and to check at the end of the test that those expectations have been met. By convention, the mockery is stored in an instance variable named context.

使用 JUnit4 编写的测试不需要扩展特定的基类,但必须指定它使用具有@RunWith(JMock.class)属性的 jMock。1这告诉 JUnit 运行器在测试类中找到一个Mockery字段并断言(在测试生命周期中的正确时间)其期望已得到满足。这要求测试类中应该只有一个模拟字段。该类JUnit4Mockery将把期望失败报告为 JUnit4 测试失败。

A test written with JUnit4 does not need to extend a specific base class but must specify that it uses jMock with the @RunWith(JMock.class) attribute.1 This tells the JUnit runner to find a Mockery field in the test class and to assert (at the right time in the test lifecycle) that its expectations have been met. This requires that there should be exactly one mockery field in the test class. The class JUnit4Mockery will report expectation failures as JUnit4 test failures.

1.在撰写本文时,JUnit 引入了 的概念Rule。我们期望扩展 jMock API 以采用此技术。

1. At the time of writing, JUnit was introducing the concept of Rule. We expect to extend the jMock API to adopt this technique.

创建模拟对象

Creating Mock Objects

这个测试使用了两个模拟海龟,我们让模拟程序创建它们。第一个是测试类中的一个字段:

This test uses two mock turtles, which we ask the mockery to create. The first is a field in the test class:

私有最终 Turtle turtle = context.mock(Turtle.class);

private final Turtle turtle = context.mock(Turtle.class);

第二个是本地测试,因此它保存在一个变量中:

The second is local to the test, so it’s held in a variable:

最终 Turtle turtle2 = context.mock(Turtle.class, "turtle2");

final Turtle turtle2 = context.mock(Turtle.class, "turtle2");

变量必须是 final 的,这样匿名期望块才能访问它——我们很快就会回到这个问题。第二个模拟海龟有一个指定的名称,turtle2。任何模拟都可以被赋予一个名称,如果测试失败,该名称将在报告中使用;默认名称是对象的类型。如果有多个相同类型的模拟对象,jMock 会强制只有一个使用默认名称;其他的必须在声明时指定名称。这是为了让失败报告在描述测试状态时清楚地说明哪个模拟实例是哪个。

The variable has to be final so that the anonymous expectations block has access to it—we’ll return to this soon. This second mock turtle has a specified name, turtle2. Any mock can be given a name which will be used in the report if the test fails; the default name is the type of the object. If there’s more than one mock object of the same type, jMock enforces that only one uses the default name; the others must be given names when declared. This is so that failure reports can make clear which mock instance is which when describing the state of the test.

带着期望进行测试

Tests with Expectations

测试在一个或多个期望块中设置其期望,例如:

A test sets up its expectations in one or more expectation blocks, for example:

上下文.检查(新期望(){{

oneOf (turtle).turn(45);

}});

context.checking(new Expectations() {{

oneOf (turtle).turn(45);

}});

期望块可以包含任意数量的期望。一个测试可以包含多个期望块;后面块中的期望将附加到前面块中的期望。期望块可以与对被测代码的调用交错。

An expectation block can contain any number of expectations. A test can contain multiple expectation blocks; expectations in later blocks are appended to those in earlier blocks. Expectation blocks can be interleaved with calls to the code under test.

期望

Expectations

期望具有以下结构:

Expectations have the following structure:

调用计数(模拟对象)。方法(参数约束);

inSequence(序列名称);

当(状态机。是(状态名称));

将(动作);

然后(状态机。是(新状态名称));

invocation-count(mock-object).method(argument-constraints);

inSequence(sequence-name);

when(state-machine.is(state-name));

will(action);

then(state-machine.is(new-state-name));

invocation-countmock-object必需的,其他所有子句都是可选的。您可以为期望赋予任意数量的inSequencewhenwillthen子句。以下是一些常见示例:

The invocation-count and mock-object are required, all the other clauses are optional. You can give an expectation any number of inSequence, when, will, and then clauses. Here are some common examples:

oneOf (turtle).turn(45); // 必须精确地告诉乌龟一次,让它转动 45 度。

atLeast(1).of (turtle).stop(); // 必须至少告诉乌龟一次让它停止。

allowing (turtle).flashLEDs(); // 可以告诉乌龟任意次

(包括不告诉),让它的 LED 闪烁。

allowing (turtle).queryPen(); will(returnValue(PEN_DOWN));

// 可以向乌龟询问它的笔

任意次,并且它总是返回 PEN_DOWN。

ignoring (turtle2); // 可以告诉乌龟 2 做任何事情。此测试忽略它。

oneOf (turtle).turn(45); // The turtle must be told exactly once to turn 45 degrees.

atLeast(1).of (turtle).stop(); // The turtle must be told at least once to stop.

allowing (turtle).flashLEDs(); // The turtle may be told any number of times,

// including none, to flash its LEDs.

allowing (turtle).queryPen(); will(returnValue(PEN_DOWN));

// The turtle may be asked about its pen any

// number of times and will always return PEN_DOWN.

ignoring (turtle2); // turtle2 may be told to do anything. This test ignores it.

调用次数

Invocation Count

调用计数用于描述我们预期在测试运行期间调用的频率。它开启了预期的定义。

The invocation count is required to describe how often we expect a call to be made during the run of the test. It starts the definition of an expectation.

exactly(n).of

exactly(n).of

预计调用n次数准确。

The invocation is expected exactly n times.

oneOf

oneOf

调用只需要一次。这是exactly(1).of

The invocation is expected exactly once. This is a convenience shorthand for exactly(1).of

atLeast(n).of

atLeast(n).of

预计调用至少n次。

The invocation is expected at least n times.

atMost(n).of

atMost(n).of

大多数情况下都需要调用n

The invocation is expected at most n times.

between(min, max).of

between(min, max).of

预计调用至少min次数和最多max次数。

The invocation is expected at least min times and at most max times.

allowing

allowing

ignoring

ignoring

允许调用任意次,包括零次。这些子句相当于atLeast(0).of,但我们使用它们来强调期望是一个存根— 它的存在是为了让测试进入行为的有趣部分。

The invocation is allowed any number of times including none. These clauses are equivalent to atLeast(0).of, but we use them to highlight that the expectation is a stub—that it’s there to get the test through to the interesting part of the behavior.

never

never

调用不是预期的。如果未设置预期,则这是默认行为。我们使用此子句向测试读者强调不应调用调用。

The invocation is not expected. This is the default behavior if no expectation has been set. We use this clause to emphasize to the reader of a test that an invocation should not be called.

allowingignoringnever也可以应用于整个对象。例如,ignoring(turtle2)表示允许所有对 的调用turtle2。类似地,never(turtle2)表示如果有任何对 的调用则失败turtle2(这与未指定对象上的任何期望相同)。如果我们添加方法期望,我们可以更精确,例如:

allowing, ignoring, and never can also be applied to an object as a whole. For example, ignoring(turtle2) says to allow all calls to turtle2. Similarly, never(turtle2) says to fail if any calls are made to turtle2 (which is the same as not specifying any expectations on the object). If we add method expectations, we can be more precise, for example:

允许(turtle2).log(with(anything()));

从不(turtle2).stop();

allowing(turtle2).log(with(anything()));

never(turtle2).stop();

将允许将日志消息发送到 turtle,但如果被告知停止,则会失败。在实践中,虽然允许精确调用很常见,但阻止单个方法很少有用。

will allow log messages to be sent to the turtle, but fail if it’s told to stop. In practice, while allowing precise invocations is common, blocking individual methods is rarely useful.

方法

Methods

通过在期望块中调用模拟对象上的方法来指定预期方法。这定义了方法的名称以及可接受的参数值。传递给期望中方法的值将进行比较以确定是否相等:

Expected methods are specified by calling the method on the mock object within an expectation block. This defines the name of the method and what argument values are acceptable. Values passed to the method in an expectation will be compared for equality:

oneOf (turtle).turn(45); // 匹配使用 45 调用的 turn()

oneOf (calculator).add(2, 2); // 匹配使用 2 和 2 调用的 add()

oneOf (turtle).turn(45); // matches turn() called with 45

oneOf (calculator).add(2, 2); // matches add() called with 2 and 2

通过使用匹配器作为子句中包装的参数,可以使调用匹配更加灵活with()

Invocation matching can be made more flexible by using matchers as arguments wrapped in with() clauses:

oneOf(calculator).add( with(lessThan(15)) , with(any(int.class))) ;

// 匹配使用小于 15 的数字和任何其他数字调用的 add()

oneOf(calculator).add(with(lessThan(15)), with(any(int.class)));

// matches add() called with a number less than 15 and any other number

所有参数都必须是匹配器,或者都必须是值:

Either all the arguments must be matchers or all must be values:

oneOf(calculator).add(with(lessThan(15)), 22 ); // 这不起作用!

oneOf(calculator).add(with(lessThan(15)), 22); // this doesn't work!

参数匹配器

Argument Matchers

最常用的匹配器在类中定义Expectations

The most commonly used matchers are defined in the Expectations class:

equal(o)

equal(o)

参数等于o,如通过调用测试期间收到的实际值所定义o.equals()。这也会递归比较数组的内容。

The argument is equal to o, as defined by calling o.equals() with the actual value received during the test. This also recursively compares the contents of arrays.

same(o)

same(o)

参数与 是同一对象o

The argument is the same object as o.

any(Class<T> type)

any(Class<T> type)

参数是任意值,包括nulltype需要该参数来强制 Java 在编译时对参数进行类型检查。

The argument is any value, including null. The type argument is required to force Java to type-check the argument at compile time.

a(Class<T> type)

a(Class<T> type)

an(Class<T> type)

an(Class<T> type)

type该参数是其子类型之一的实例。

The argument is an instance of type or of one of its subtypes.

aNull(Class<T> type)

aNull(Class<T> type)

参数为空。该type参数用于强制 Java 在编译时对参数进行类型检查。

The argument is null. The type argument is to force Java to type-check the argument at compile time.

aNonNull(Class<T> type)

aNonNull(Class<T> type)

参数不为空。该type参数用于强制 Java 在编译时对参数进行类型检查。

The argument is not null. The type argument is to force Java to type-check the argument at compile time.

not(m)

not(m)

参数与匹配器不匹配m

The argument does not match the matcher m.

anyOf(m1, m2, m3, [...])

anyOf(m1, m2, m3, [...])

该参数至少与匹配器m1m2m3、中的一个匹配[...]

The argument matches at least one of the matchers m1, m2, m3, [...].

allOf(m1, m2, m3, [...])

allOf(m1, m2, m3, [...])

该参数与所有匹配器m1、、、匹配。m2m3[...]

The argument matches all of the matchers m1, m2, m3, [...].

Hamcrest 类的静态工厂方法提供了更多匹配器Matchers,这些匹配器可以静态导入到测试类中。为了获得更高的精度,可以使用 Hamcrest 库编写自定义匹配器。

More matchers are available from static factory methods of the Hamcrest Matchers class, which can be statically imported into the test class. For more precision, custom matchers can be written using the Hamcrest library.

操作

Actions

通过在调用后添加子句,期望还可以指定匹配时要执行的操作will()。例如,此期望将在调用PEN_DOWN时返回:queryPen()

An expectation can also specify an action to perform when it is matched, by adding a will() clause after the invocation. For example, this expectation will return PEN_DOWN when queryPen() is called:

允许 (turtle).queryPen();将 (returnValue(PEN_DOWN));

allowing (turtle).queryPen(); will(returnValue(PEN_DOWN));

jMock 提供了几个标准操作,程序员可以通过实现Action接口来提供自定义操作。标准操作包括:

jMock provides several standard actions, and programmers can provide custom actions by implementing the Action interface. The standard actions are:

will(returnValue(v))

will(returnValue(v))

返回v给调用者。

Return v to the caller.

will(returnIterator(c))

will(returnIterator(c))

返回一个用于收集的迭代器c给调用者。

Return an iterator for collection c to the caller.

will(returnIterator(v1, v2, [...], vn))

will(returnIterator(v1, v2, [...], vn))

每次调用时v1都返回一个针对元素的新迭代器。v2

Return a new iterator over elements v1 to v2 on each invocation.

will(throwException(e))

will(throwException(e))

调用时抛出异常e

Throw exception e when called.

will(doAll(a1, a2, [...], an))

will(doAll(a1, a2, [...], an))

每次调用时a1执行所有操作。an

Perform all the actions a1 to an on every invocation.

序列

Sequences

指定期望的顺序不必与调用它们的顺序一致。如果调用顺序很重要,可以通过在测试中添加 来强制执行Sequence。测试可以创建多个序列,并且期望可以同时成为多个序列的一部分。创建 的语法Sequence是:

The order in which expectations are specified does not have to match the order in which their invocations are called. If invocation order is significant, it can be enforced in a test by adding a Sequence. A test can create more than one sequence and an expectation can be part of more than once sequence at a time. The syntax for creating a Sequence is:

序列sequence-variable = context.sequence("序列名称");

Sequence sequence-variable = context.sequence("sequence-name");

要期望一系列调用,请创建一个Sequence对象,按预期顺序编写期望,并inSequence()为每个相关期望添加一个子句。序列中的期望可以有任意调用计数。例如:

To expect a sequence of invocations, create a Sequence object, write the expectations in the expected order, and add an inSequence() clause to each relevant expectation. Expectations in a sequence can have any invocation count. For example:

context.checking(new Expectations(){{

final Sequence drawing = context.sequence(“drawing”);

允许(turtle).queryColor();将(returnValue(BLACK));



至少(1).of(turtle).forward(10);inSequence(drawing);

oneOf(turtle).turn(45);inSequence(drawing);

oneOf(turtle).forward(10);inSequence(drawing);

}});

context.checking(new Expectations() {{

final Sequence drawing = context.sequence("drawing");

allowing (turtle).queryColor(); will(returnValue(BLACK));



atLeast(1).of (turtle).forward(10); inSequence(drawing);

oneOf (turtle).turn(45); inSequence(drawing);

oneOf (turtle).forward(10); inSequence(drawing);

}});

在这里,queryColor()调用不是按顺序进行的,可以在任何时间进行。

Here, the queryColor() call is not in the sequence and can take place at any time.

States

可以将调用限制为仅当条件为真时才发生,其中条件定义为处于给定状态的状态机。状态机可以在状态名称指定的状态之间切换。测试可以创建多个状态机,并且调用可以限制为一个或多个条件。创建状态机的语法是:

Invocations can be constrained to occur only when a condition is true, where a condition is defined as a state machine that is in a given state. State machines can switch between states specified by state names. A test can create multiple state machines, and an invocation can be constrained to one or more conditions. The syntax for creating a state machine is:

状态state-machine-name =

context.states(" state-machine-name ").startsAs("初始状态");

States state-machine-name =

context.states("state-machine-name").startsAs("initial-state");

初始状态是可选的;如果未指定,状态机将从未命名的初始状态启动。

The initial state is optional; if not specified, the state machine starts in an unnamed initial state.

将这些子句添加到期望中,以约束它们匹配给定状态下的调用,或者在调用后切换状态机的状态:

Add these clauses to expectations to constrain them to match invocations in a given state, or to switch the state of a state machine after an invocation:

when(stateMachine.is("state-name"));

when(stateMachine.is("state-name"));

stateMachine限制最后的期望仅当处于状态时才发生"state-name"

Constrains the last expectation to occur only when stateMachine is in the state "state-name".

when(stateMachine.isNot("state-name"));

when(stateMachine.isNot("state-name"));

stateMachine限制最后的期望仅当不处于状态时才发生"state-name"

Constrains the last expectation to occur only when stateMachine is not in the state "state-name".

then(stateMachine.is("state-name"));

then(stateMachine.is("state-name"));

更改stateMachine"state-name"调用发生时的状态。

Changes stateMachine to be in the state "state-name" when the invocation occurs.

此示例turtle仅允许在笔放下时移动:

This example allows turtle to move only when the pen is down:

context.checking(新期望(){{

最终状态笔= context.states(“pen”)。startsAs(“up”);

允许(turtle)。queryColor();将(returnValue(BLACK));



允许(turtle)。penDown();然后(pen.is(“down”));

允许(turtle)。penUp();然后(pen.is(“up”));



至少(1)。(turtle)。forward(15);当(pen.is(“down”));

一(turtle)。turn(90);当(pen.is(“down”));

一(turtle)。forward(10);当(pen.is(“down”));

}}

context.checking(new Expectations() {{

final States pen = context.states("pen").startsAs("up");

allowing (turtle).queryColor(); will(returnValue(BLACK));



allowing (turtle).penDown(); then(pen.is("down"));

allowing (turtle).penUp(); then(pen.is("up"));



atLeast(1).of (turtle).forward(15); when(pen.is("down"));

one (turtle).turn(90); when(pen.is("down"));

one (turtle).forward(10); when(pen.is("down"));

}}

请注意,带有状态的期望不定义序列;Sequence如果顺序很重要,它们可以与约束相结合。与之前一样,queryColor()调用不包含在状态中,因此可以随时调用。

Notice that expectations with states do not define a sequence; they can be combined with Sequence constraints if order is significant. As before, the queryColor() call is not included in the states, and so can be called at any time.

附录 B. 编写 Hamcrest Matcher

Appendix B. Writing a Hamcrest Matcher

介绍

Introduction

尽管 Hamcrest 1.2 附带了一个大型匹配器库,但有时这些匹配器无法让您准确指定断言或期望,无法传达您的意思或保持测试的灵活性。在这种情况下,您可以轻松定义一个无缝扩展 JUnit 和 jMock API 的新匹配器。

Although Hamcrest 1.2 comes with a large library of matchers, sometimes these do not let you specify an assertion or expectation accurately enough to convey what you mean or to keep your tests flexible. In such cases, you can easily define a new matcher that seamlessly extends the JUnit and jMock APIs.

匹配器是实现以下接口的对象org.hamcrest.Matcher

A matcher is an object that implements the org.hamcrest.Matcher interface:

公共接口 SelfDescribing {

void describeTo(Description description);

}



公共接口 Matcher<T> 扩展了 SelfDescribing {

布尔匹配(Object item);

void describeMismatch(Object item, Description mismatchDescription);

}

public interface SelfDescribing {

void describeTo(Description description);

}



public interface Matcher<T> extends SelfDescribing {

boolean matches(Object item);

void describeMismatch(Object item, Description mismatchDescription);

}

匹配器做两件事:

A matcher does two things:

• 报告参数值是否满足约束(matches()方法);

• Reports whether a parameter value meets the constraint (the matches() method);

• 生成可读的描述以包含在测试失败消息中(describeTo()SelfDescribing接口继承的方法和describeMismatch()方法)。

• Generates a readable description to be included in test failure messages (the describeTo() method inherited from the SelfDescribing interface and the describeMismatch() method).

新的匹配器类型

A New Matcher Type

作为示例,我们将编写一个新的匹配器,用于测试字符串是否以给定的前缀开头。它可以在测试中使用,如下所示。请注意,匹配器无缝扩展了断言:在使用时,内置匹配器和第三方匹配器之间没有明显的区别。

As an example, we will write a new matcher that tests whether a string starts with a given prefix. It can be used in tests as shown below. Note that the matcher seamlessly extends the assertion: there is no visible difference between built-in and third-party matchers at the point of use.

图像

要编写新的匹配器,我们必须实现两件事:一个实现Matcher接口的新类和startsWith()工厂函数,以便我们在测试中使用新的匹配器时可以很好地读取断言。

To write a new matcher, we must implement two things: a new class that implements the Matcher interface and the startsWith() factory function for our assertions to read well when we use the new matcher in our tests.

要编写匹配器类型,我们需要扩展 Hamcrest 的一个抽象基类,而不是Matcher直接实现接口。1为了满足我们的需求,我们可以扩展TypeSafeMatcher<String>,它检查空值和类型安全,将匹配的转换为ObjectString并调用我们子类中的模板方法 [Gamma94] 。

To write a matcher type, we extend one of Hamcrest’s abstract base classes, rather than implementing the Matcher interface directly.1 For our needs, we can extend TypeSafeMatcher<String>, which checks for nulls and type safety, casts the matched Object to a String, and calls the template methods [Gamma94] in our subclass.

1.这使得 Hamcrest 团队可以向Matcher接口添加方法,而无需破坏实现该接口的所有代码,因为他们还可以向基类添加默认实现。

1. This lets the Hamcrest team add methods to the Matcher interface without breaking all the code that implements that interface, because they can also add a default implementation to the base class.

public class StringStartsWithMatcher extends TypeSafeMatcher<String> {

private final String expectedPrefix;



public StringStartsWithMatcher(String expectedPrefix) {

this.expectedPrefix = expectedPrefix;

}

@Override

protected boolean matchesSafely(String actual) {

return actual.startsWith(expectedPrefix);

}

@Override

public void describeTo(Description matchDescription) {

matchDescription.appendText("以 ... 开头的字符串 ")

.appendValue(expectedPrefix);

}

@Override protected void

describeMismatchSafely(String actual, Description mismatchDescription) {

String actualPrefix =

actual.substring(0, Math.min(actual.length(), expectedPrefix.length()));



mismatchDescription.appendText("以 ... 开头 ")

.appendValue(actualPrefix);

}

}

public class StringStartsWithMatcher extends TypeSafeMatcher<String> {

private final String expectedPrefix;



public StringStartsWithMatcher(String expectedPrefix) {

this.expectedPrefix = expectedPrefix;

}

@Override

protected boolean matchesSafely(String actual) {

return actual.startsWith(expectedPrefix);

}

@Override

public void describeTo(Description matchDescription) {

matchDescription.appendText("a string starting with ")

.appendValue(expectedPrefix);

}

@Override protected void

describeMismatchSafely(String actual, Description mismatchDescription) {

String actualPrefix =

actual.substring(0, Math.min(actual.length(), expectedPrefix.length()));



mismatchDescription.appendText("started with ")

.appendValue(actualPrefix);

}

}

匹配器对象必须是无状态的

Matcher Objects Must Be Stateless

图像

在分派每个调用时,jMock 使用匹配器来查找与调用参数匹配的期望。这意味着它将在测试期间多次调用匹配器,甚至可能在期望已经匹配和调用之后。事实上,jMock 无法保证何时以及调用匹配器的次数。这对无状态匹配器没有影响,但有状态匹配器的行为是不可预测的。

When dispatching each invocation, jMock uses the matchers to find an expectation that matches the invocation’s arguments. This means that it will call the matchers many times during the test, maybe even after the expectation has already been matched and invoked. In fact, jMock gives no guarantees of when and how many times it will call the matchers. This has no effect on stateless matchers, but the behavior of stateful matchers is unpredictable.

如果您想要在响应调用时保持状态,请编写自定义 jMock Action,而不是Matcher

If you want to maintain state in response to invocations, write a custom jMock Action, not a Matcher.

describeTo()和生成的文本describeMismatch()必须遵循某些语法约定,以适应 JUnit 和 jMock 生成的错误消息。尽管 JUnit 和 jMock 生成不同的消息,但完成句子“expected descriptionbut it mismatch-description”的匹配器描述将适用于这两个库。使用 的描述完成的句子StringStartsWithMatcher将类似于:

The text generated by the describeTo() and describeMismatch() must follow certain grammatical conventions to fit into the error messages generated by JUnit and jMock. Although JUnit and jMock generate different messages, matcher descriptions that complete the sentence “expected description but it mismatch-description” will work with both libraries. That sentence completed with the StringStartsWithMatcher’s descriptions would be something like:

预期字符串以“Cheese”开头,但它以“Bananas”开头

expected a string starting with "Cheese" but it started with "Bananas"

为了使新的匹配器无缝地融入 JUnit 和 jMock,我们还编写了一个工厂方法来创建 的实例StringStartsWithMatcher

To make the new matcher fit seamlessly into JUnit and jMock, we also write a factory method that creates an instance of the StringStartsWithMatcher.

公共静态 Matcher<String> aStringStartingWith(String prefix ) {

返回新的 StringStartsWithMatcher(prefix);

}

public static Matcher<String> aStringStartingWith(String prefix ) {

return new StringStartsWithMatcher(prefix);

}

工厂方法的目的在于使测试代码读起来清晰,因此请考虑在断言或期望中使用时它会是什么样子。

The point of the factory method is to make the test code read clearly, so consider how it will look when used in an assertion or expectation.

这就是编写匹配器的全部内容。

And that’s all there is to writing a matcher.

参考书目

Bibliography

[Abelson96] Abelson, Harold 和 Gerald Sussman。计算机程序的结构和解释。麻省理工学院出版社,1996 年,ISBN 978-0262011532。

[Abelson96] Abelson, Harold and Gerald Sussman. Structure and Interpretation of Computer Programs. MIT Press, 1996, ISBN 978-0262011532.

[Beck99] Beck, Kent。极限编程解析:拥抱变化。Addison-Wesley,1999,ISBN 978-0321278654。

[Beck99] Beck, Kent. Extreme Programming Explained: Embrace Change. Addison-Wesley, 1999, ISBN 978-0321278654.

[Beck02] Beck, Kent。测试驱动开发:示例。Addison-Wesley,2002,ISBN 978-0321146530。

[Beck02] Beck, Kent. Test Driven Development: By Example. Addison-Wesley, 2002, ISBN 978-0321146530.

[Begel08] Begel, Andrew 和 Beth Simon。“大学毕业生在第一份软件开发工作中的挣扎。” 载于:SIGCSE Bulletin, 40,第 1 期(2008 年 3 月):226–230,ACM,ISSN 0097-8418。

[Begel08] Begel, Andrew and Beth Simon. “Struggles of New College Graduates in Their First Software Development Job.” In: SIGCSE Bulletin, 40, no. 1 (March 2008): 226–230, ACM, ISSN 0097-8418.

[Cockburn04] Cockburn, Alistair。Crystal Clear:一种针对小型团队的人力驱动方法。Addison-Wesley Professional,2004 年 10 月 29 日,ISBN 0201699478。

[Cockburn04] Cockburn, Alistair. Crystal Clear: A Human-Powered Methodology for Small Teams. Addison-Wesley Professional, October 29, 2004, ISBN 0201699478.

[Cockburn08] Cockburn, Alistair。六边形架构:端口和适配器(“对象结构”)。2008 年 6 月 19 日,http://alistair.cockburn.us/Hexagonal+architecture

[Cockburn08] Cockburn, Alistair. Hexagonal Architecture: Ports and Adapters (“Object Structural”). June 19, 2008, http://alistair.cockburn.us/Hexagonal+architecture.

[Cohn05] Cohn, Mike。敏捷估算与规划。Prentice Hall,2005,ISBN 978-0131479418。

[Cohn05] Cohn, Mike. Agile Estimating and Planning. Prentice Hall, 2005, ISBN 978-0131479418.

[Demeyer03] Demeyer, Serge, Stéphane Ducasse 和 Oscar Nierstrasz。面向对象的重构模式。http ://scg.unibe.ch/download/oorp/

[Demeyer03] Demeyer, Serge, Stéphane Ducasse, and Oscar Nierstrasz. Object-Oriented Reengineering Patterns. http://scg.unibe.ch/download/oorp/.

[Evans03] Evans, Eric。领域驱动设计:解决软件核心的复杂性。Addison-Wesley,2003 年,ISBN 978-0321125217。

[Evans03] Evans, Eric. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003, ISBN 978-0321125217.

[Feathers04] Feathers, Michael.有效使用遗留代码. Prentice Hall, 2004, ISBN 978-0131177055。

[Feathers04] Feathers, Michael. Working Effectively with Legacy Code. Prentice Hall, 2004, ISBN 978-0131177055.

[Fowler99] Fowler, Martin。重构:改进现有代码的设计。Addison-Wesley,1999,ISBN 978-0201485677。

[Fowler99] Fowler, Martin. Refactoring: Improving the Design of Existing Code. Addison-Wesley, 1999, ISBN 978-0201485677.

[Freeman04] Freeman, Steve、Tim Mackinnon、Nat Pryce 和 Joe Walnes。“模拟角色,而非对象。”收录于:第 19 届 ACM SIGPLAN 面向对象编程系统、语言和应用程序会议指南,OOPLSA,温哥华,不列颠哥伦比亚省,2004 年 10 月,纽约:ACM,ISBN 1581138334,http://portal.acm.org/citation.cfm ?doid=1028664.1028765 。

[Freeman04] Freeman, Steve, Tim Mackinnon, Nat Pryce, and Joe Walnes. “Mock Roles, Not Objects.” In: Companion to the 19th ACM SIGPLAN Conference on Object-Oriented Programming Systems, Languages, and Applications, OOPLSA, Vancouver, BC, October 2004, New York: ACM, ISBN 1581138334, http://portal.acm.org/citation.cfm?doid=1028664.1028765.

[Freeman06] Freeman, Steve 和 Nat Pryce。“在 Java 中发展一种嵌入式领域特定语言。” 收录于:第 21 届 ACM SIGPLAN 面向对象编程系统、语言和应用程序会议指南OOPLSA,俄勒冈州波特兰,2006 年 10 月,纽约:ACM,http://www.jmock.org/oopsla06.pdf

[Freeman06] Freeman, Steve and Nat Pryce. “Evolving an Embedded Domain-Specific Language in Java.” In: Companion to the 21st ACM SIGPLAN Conference on Object-Oriented Programming Systems, Languages, and Applications, OOPLSA, Portland, Oregon, October 2006, New York: ACM, http://www.jmock.org/oopsla06.pdf.

[Gall03] Gall, John。《系统圣经:大型和小型系统初学者指南》。General Systemantics Pr/Liberty,2003 年,ISBN 978-0961825171。

[Gall03] Gall, John. The Systems Bible: The Beginner’s Guide to Systems Large and Small. General Systemantics Pr/Liberty, 2003, ISBN 978-0961825171.

[Gamma94] Gamma, Erich、Richard Helm、Ralph Johnson 和 John Vlissides。设计模式:可重用面向对象软件的元素。Addison-Wesley,1994 年。

[Gamma94] Gamma, Erich, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994.

[Graham93] Graham, Paul. On Lisp . Prentice Hall, 1993, ISBN 0130305529, http://www.paulgraham.com/onlisp.html .

[Graham93] Graham, Paul. On Lisp. Prentice Hall, 1993, ISBN 0130305529, http://www.paulgraham.com/onlisp.html.

[Hunt99] Hunt, Andrew 和 David Thomas。《实用程序员:从熟练工到大师》。Addison-Wesley Professional,1999 年 10 月 30 日,ISBN 020161622X。

[Hunt99] Hunt, Andrew and David Thomas. The Pragmatic Programmer: From Journeyman to Master. Addison-Wesley Professional, October 30, 1999, ISBN 020161622X.

[Kay98] Kay, Alan。发送至 Squeak 邮件列表的电子邮件消息。1998 年 10 月 10 日,http://lists.squeakfoundation.org/pipermail/squeak-dev/1998-October/017019.html

[Kay98] Kay, Alan. Email Message Sent to the Squeak Mailing List. October 10, 1998, http://lists.squeakfoundation.org/pipermail/squeak-dev/1998-October/017019.html.

[Kerievsky04] Kerievsky, Joshua。重构模式。Addison-Wesley,2004 年,ISBN 978-0321213358。

[Kerievsky04] Kerievsky, Joshua. Refactoring to Patterns. Addison-Wesley, 2004, ISBN 978-0321213358.

[Kernighan76] Kernighan、Brian 和 PJ Plauger。软件工具。艾迪生·韦斯利,1976 年,ISBN 978-0201036695。

[Kernighan76] Kernighan, Brian and P. J. Plauger. Software Tools. Addison-Wesley, 1976, ISBN 978-0201036695.

[Lieberherr88] Lieberherr, Karl, Ian Holland 和 Arthur Riel。“面向对象编程:一种客观的风格。” 收录于:OOPSLA, 23,第 11 期 (1988):第 323–334 页。

[Lieberherr88] Lieberherr, Karl, Ian Holland, and Arthur Riel. “Object-Oriented Programming: An Objective Sense of Style.” In: OOPSLA, 23, no. 11 (1988): 323–334.

[LIFT]文学功能测试框架。https ://lift.dev.java.net/

[LIFT] Framework for Literate Functional Testing. https://lift.dev.java.net/.

[Mackinnon00] Mackinnon, Tim、Steve Freeman 和 Philip Craig。“Endo-Testing:使用模拟对象进行单元测试。”收录于:Giancarlo Succi 和 Michele Marchesi 的《极限编程考量》,Addison-Wesley,2001 年,第 287-301 页,ISBN 978-0201710403。

[Mackinnon00] Mackinnon, Tim, Steve Freeman, and Philip Craig. “Endo-Testing: Unit Testing with Mock Objects.” In: Giancarlo Succi and Michele Marchesi, Extreme Programming Examined, Addison-Wesley, 2001, pp. 287–301, ISBN 978-0201710403.

[Magee06] Magee, Jeff 和 Jeff Kramer。并发:状态模型和 Java 程序。Wiley,2006 年,ISBN 978-0470093559。

[Magee06] Magee, Jeff and Jeff Kramer. Concurrency: State Models & Java Programs. Wiley, 2006, ISBN 978-0470093559.

[Martin02] Martin, Robert C.敏捷软件开发、原则、模式和实践。Prentice Hall,2002,ISBN 978-0135974445。

[Martin02] Martin, Robert C. Agile Software Development, Principles, Patterns, and Practices. Prentice Hall, 2002, ISBN 978-0135974445.

[Meszaros07] Meszaros, Gerard。xUnit测试模式:重构测试代码。Addison-Wesley,2007 年,ISBN 978-0131495050。

[Meszaros07] Meszaros, Gerard. xUnit Test Patterns: Refactoring Test Code. Addison-Wesley, 2007, ISBN 978-0131495050.

[Meyer91] Meyer, Betrand。艾菲尔:语言。Prentice Hall,1991,ISBN 978-0132479257。

[Meyer91] Meyer, Betrand. Eiffel: The Language. Prentice Hall, 1991, ISBN 978-0132479257.

[Mugridge05] Mugridge, Rick 和 Ward Cunningham。适合开发软件:集成测试框架。Prentice Hall,2005 年,ISBN 978-0321269348。

[Mugridge05] Mugridge, Rick and Ward Cunningham. Fit for Developing Software: Framework for Integrated Tests. Prentice Hall, 2005, ISBN 978-0321269348.

[Schuh01] Schuh, Peter 和 Stephanie Punke。ObjectMother :简化 XP 中的测试对象创建。XP Universe,2001 年。

[Schuh01] Schuh, Peter and Stephanie Punke. ObjectMother: Easing Test Object Creation In XP. XP Universe, 2001.

[Schwaber01] Schwaber, Ken 和 Mike Beedle。使用 Scrum 进行敏捷软件开发。Prentice Hall,2001 年,ISBN 978-0130676344。

[Schwaber01] Schwaber, Ken and Mike Beedle. Agile Software Development with Scrum. Prentice Hall, 2001, ISBN 978-0130676344.

[Shore07] Shore, James 和 Shane Warden。《敏捷开发的艺术》。O'Reilly Media,2007 年,ISBN 978-0596527679。

[Shore07] Shore, James and Shane Warden. The Art of Agile Development. O’Reilly Media, 2007, ISBN 978-0596527679.

[Wirfs-Brock03] Wirfs-Brock,Rebecca 和 Alan McKean。对象设计:角色、职责和协作。Addison-Wesley,2003 年,ISBN 0201379430。

[Wirfs-Brock03] Wirfs-Brock, Rebecca and Alan McKean. Object Design: Roles, Responsibilities, and Collaborations. Addison-Wesley, 2003, ISBN 0201379430.

[Woolf98] Woolf, Bobby。“空对象。” 出自:程序设计模式语言 3。由 Robert Martin、Dirk Riehle 和 Frank Buschmann 编辑。Addison-Wesley,1998 年,http://www.cse.wustl.edu/~schmidt/PLoP-96/woolf1.ps.gz

[Woolf98] Woolf, Bobby. “Null Object.” In: Pattern Languages of Program Design 3. Edited by Robert Martin, Dirk Riehle, and Frank Buschmann. Addison-Wesley, 1998, http://www.cse.wustl.edu/~schmidt/PLoP-96/woolf1.ps.gz.

[Yourdon79] Yourdon, Edward 和 Larry Constantine。结构化设计:计算机程序和系统设计学科的基础。Prentice Hall,1979 年,ISBN 978-0138544713。

[Yourdon79] Yourdon, Edward and Larry Constantine. Structured Design: Fundamentals of a Discipline of Computer Program and Systems Design. Prentice Hall, 1979, ISBN 978-0138544713.

指数

Index

一个

A

a(),jMock,340

a(), jMock, 340

AbstractTableModel班级,152

AbstractTableModel class, 152

验收测试,4、7-10

acceptance tests, 4, 7–10

不及格,6–7 , 39–40 , 42 , 271

failing, 6–7, 39–40, 42, 271

对于变更的要求,40

for changed requirements, 40

对于已完成的功能,40

for completed features, 40

对于退化的情况,41

for degenerate cases, 41

对于新功能,6、39–40、105、225

for new features, 6, 39–40, 105, 225

可读性,42

readability of, 42

Action接口341,344

Action interface, 341, 344

ActionListener接口, 185 , 187

ActionListener interface, 185, 187

ActiveDirectory,232

ActiveDirectory, 232

适配器, 48 , 70–71 , 284 , 297

adapters, 48, 70–71, 284, 297

addSniper()180

addSniper(), 180

addUserRequestListenerFor()187

addUserRequestListenerFor(), 187

调整52–53,238

adjustments, 52–53, 238

嘲讽,58

mocking, 58

@After注释, 23 , 96

@After annotation, 23, 96

@AfterClass注释, 223

@AfterClass annotation, 223

敏捷开发,35,47,81,83,205,329

Agile Development, 35, 47, 81, 83, 205, 329

混叠,50

aliasing, 50

allOf(),哈姆克雷斯特,340

allOf(), Hamcrest, 340

津贴146,277-279

allowances, 146, 277–279

allowing(),jMock ,145–146,181,211,243,278,278,339

allowing(), jMock, 145–146, 181, 211, 243, 278, 278, 339

an(),jMock,340

an(), jMock, 340

announce(),jMock,187

announce(), jMock, 187

announceClosed()106–107176

announceClosed(), 106–107, 176

Announcer班级187,192

Announcer class, 187, 192

aNonNull(),jMock,340

aNonNull(), jMock, 340

ant 构建工具, 95

ant build tool, 95

aNull(),jMock,340

aNull(), jMock, 340

any(),哈姆克雷斯特,340

any(), Hamcrest, 340

anyOf(),哈姆克雷斯特,340

anyOf(), Hamcrest, 340

Apache Commons IO 库,221

Apache Commons IO library, 221

应用程序模型, 48

application model, 48

ApplicationRunner类,85,89–92,106–107,140,153,168,175–177,183,207,254

ApplicationRunner class, 85, 89–92, 106–107, 140, 153, 168, 175–177, 183, 207, 254

aRowChangedEvent()157,162

aRowChangedEvent(), 157, 162

ArrayIndexOutOfBoundsException217

ArrayIndexOutOfBoundsException, 217

aSniperThatIs()161–162278

aSniperThatIs(), 161–162, 278

assertColumnEquals()157

assertColumnEquals(), 157

assertEquals()JUnit21–22,276

assertEquals(), JUnit, 21–22, 276

assertEventually()321–323,326

assertEventually(), 321–323, 326

assertFalse() JUnit ,24,255

assertFalse(), JUnit, 24, 255

断言22,254-255

assertions, 22, 254–255

延伸,343–345

extending, 343–345

失败24,268

failing, 24, 268

留言,268

messages for, 268

命名,86

naming, 86

狭窄255,275–276

narrowness of, 255, 275–276

数量,252

quantity of, 252

与同步相比,326

vs. synchronizations, 326

与测试设置相比,211

vs. test setup, 211

assertIsSatisfied(),JUnit,271

assertIsSatisfied(), JUnit, 271

assertNull(),JUnit,21–22

assertNull(), JUnit, 21–22

assertRowMatchesSnapshot()180

assertRowMatchesSnapshot(), 180

assertThat()、JUnit 、24–25、253–255、268、276

assertThat(), JUnit, 24–25, 253–255, 268, 276

assertTrue()JUnit21–22,24,255

assertTrue(), JUnit, 21–22, 24, 255

异步87,180,216,262

asynchrony, 87, 180, 216, 262

测试,315–327

testing, 315–327

atLeast() jMock ,127,278,339

atLeast(), jMock, 127, 278, 339

atMost(),jMock,339

atMost(), jMock, 339

AtomicBigCounter班级,311–312

AtomicBigCounter class, 311–312

AtomicInteger班级,309–310

AtomicInteger class, 309–310

attachModelListener()、摇摆乐,156–157

attachModelListener(), Swing, 156–157

Auction接口,62,126-131,136,155,193,203

Auction interface, 62, 126–131, 136, 155, 193, 203

拍卖狙击手,75–226

Auction Sniper, 75–226

竞标79,84,105-121,126-131,162​​

bidding, 79, 84, 105–121, 126–131, 162

对于多个项目,175

for multiple items, 175

停止79,205–213

stopping, 79, 205–213

连接, 108 , 111 , 179 , 183

connecting, 108, 111, 179, 183

断开连接,219–220

disconnecting, 219–220

显示状态, 97–98 , 128 , 144–146 , 152–155 , 159–160 , 171 , 323

displaying state of, 97–98, 128, 144–146, 152–155, 159–160, 171, 323

失败,215–217

failing, 215–217

参加拍卖79、84、91、94、98–100、179–181、184–186、197–199

joining auctions, 79, 84, 91, 94, 98–100, 179–181, 184–186, 197–199

失败,79,84,91,100–102,125,130,164,205–206

losing, 79, 84, 91, 100–102, 125, 130, 164, 205–206

投资组合,199

portfolio of, 199

重构,191–203

refactoring, 191–203

同步106,301

synchronizing, 106, 301

表模型149–152,156–160,166

table model for, 149–152, 156–160, 166

翻译拍卖信息112–118,139–142,217

translating messages from auction, 112–118, 139–142, 217

更新当前价格,118–121

updating current price, 118–121

用户界面79,84,96–97,149–173,183–188,207–208,212,316

user interface of, 79, 84, 96–97, 149–173, 183–188, 207–208, 212, 316

行走骨架79,83–88

walking skeleton for, 79, 83–88

拍卖结束时84、94

when an auction is closed, 84, 94

获胜,79139–148162–164

winning, 79, 139–148, 162–164

auctionClosed(), 25 , 58 , 116–117 , 119–120 , 123–125

auctionClosed(), 25, 58, 116–117, 119–120, 123–125

AuctionEvent班级,134–136

AuctionEvent class, 134–136

AuctionEventListener接口,19,26,61,113,117,120,123–124,141,192–193,217–220

AuctionEventListener interface, 19, 26, 61, 113, 117, 120, 123–124, 141, 192–193, 217–220

auctionFailed()217–220

auctionFailed(), 217–220

AuctionHouse接口196,210

AuctionHouse interface, 196, 210

AuctionLogDriver班级221,224

AuctionLogDriver class, 221, 224

AuctionMessageTranslator25–27,61,112–118,134–136,154,192,195,217–219,222,224,226​​

AuctionMessageTranslator class, 25–27, 61, 112–118, 134–136, 154, 192, 195, 217–219, 222, 224, 226

AuctionMessageTranslatorTest班级,141

AuctionMessageTranslatorTest class, 141

AuctionSearchStressTests班级,307–309

AuctionSearchStressTests class, 307–309

AuctionSniper类,62,123–134,154–155,172–173,192,198–199,208,210–212

AuctionSniper class, 62, 123–134, 154–155, 172–173, 192, 198–199, 208, 210–212

AuctionSniperDriver班级,91,153,168,184,207,254

AuctionSniperDriver class, 91, 153, 168, 184, 207, 254

AuctionSniperEndToEndTest班级85,152,183

AuctionSniperEndToEndTest class, 85, 152, 183

AuctionSniperTest班级,218

AuctionSniperTest class, 218

B

@Before注释, 23

@Before annotation, 23

between(),jMock,339

between(), jMock, 339

bidsHigherAndReportsBiddingWhenNewPriceArrives()127,143

bidsHigherAndReportsBiddingWhenNewPriceArrives(), 127, 143

“预先进行大型设计”,35

“Big Design Up Front,” 35

BlockingQueue班级,93

BlockingQueue class, 93

突破技术59–61,136

breaking out technique, 59–61, 136

芽接技术59,61–62,209

budding off technique, 59, 61–62, 209

建造

build

自动化9,36–37,95

automated, 9, 36–37, 95

包含的功能,8

features included in, 8

从项目开始,31

from the start of a project, 31

build()258–261

build(), 258–261

建造者模式,66,337

Builder pattern, 66, 337

构建器。请参阅 测试数据构建器254

builders. See test data builders, 254

捆绑技术59–60,62,154

bundling up technique, 59–60, 62, 154

C

C# 编程语言,225

C# programming language, 225

cannotTranslateMessage()第222–223页

cannotTranslateMessage(), 222–223

CatalogTest班级21,23

CatalogTest class, 21, 23

Chat类,112,115,129–130,185,192,219

Chat class, 112, 115, 129–130, 185, 192, 219

封装,193–195

encapsulating, 193–195

chatDisconnectorFor()220,226

chatDisconnectorFor(), 220, 226

ChatManager班级101,129

ChatManager class, 101, 129

ChatManagerListener接口, 92

ChatManagerListener interface, 92

check(),WindowLicker,187

check(), WindowLicker, 187

checking() jMock ,210,337

checking(), jMock, 210, 337

课程,14

classes, 14

连贯,12

coherent, 12

上下文无关, 55

context-independent, 55

将集合封装到,136

encapsulating collections into, 136

助手,93

helper, 93

层次结构, 16 , 67

hierarchy of, 16, 67

内部特征,237

internal features of, 237

松散耦合,11-12

loosely coupled, 11–12

嘲讽223–224,235–237

mocking, 223–224, 235–237

命名, 63 , 159–160 , 238 , 285 , 297

naming, 63, 159–160, 238, 285, 297

紧密耦合,12

tightly coupled, 12

Clock接口,230–232

Clock interface, 230–232

代码

code

适应,172

adapting, 172

假设,42

assumptions about, 42

清理60,118,125,131,137,245,262-264​​

cleaning up, 60, 118, 125, 131, 137, 245, 262–264

编译, 136

compiling, 136

声明层,65

declarative layer of, 65

难以测试44,229

difficult to test, 44, 229

外部质量,10–11

external quality of, 10–11

实施层,65

implementation layer of, 65

内部质量,10–11,60

internal quality of, 10–11, 60

松散耦合,11-12

loosely coupled, 11–12

维护, 12 , 125

maintenance of, 12, 125

可读性51,162,173,226,247​​

readability of, 51, 162, 173, 226, 247

重新实施,60

reimplementing, 60

紧密耦合,12

tightly coupled, 12

代码异味,63,181

code smells, 63, 181

凝聚力,11-12

cohesion, 11–12

合作者16,279

collaborators, 16, 279

收藏

collections

封装,136

encapsulating, 136

与域类型相比,213

vs. domain types, 213

命令, 78 , 278

commands, 78, 278

commit()279

commit(), 279

沟通模式,14,58

communication patterns, 14, 58

通信协议, 58 , 63

communication protocols, 58, 63

组件驱动程序,90

ComponentDriver, 90

“复合材料比各部分之和简单” ,53–54、60、62

“composite simpler than the sum of its parts,” 53–54, 60, 62

并发,301–306,309,313–316

concurrency, 301–306, 309, 313–316

connect(),打击,100

connect(), Smack, 100

connection()100

connection(), 100

Connextra,330–332

Connextra, 330–332

常数,255

constants, 255

构造函数

constructors

臃肿,238–242

bloated, 238–242

实际行为,195

real behavior in, 195

容器管理事务, 293

container-managed transactions, 293

containsTotalSalesFor()264

containsTotalSalesFor(), 264

上下文独立性,54–57,233,305

context independence, 54–57, 233, 305

CountDownLatch班级,194

CountDownLatch class, 194

耦合,11-12

coupling, 11–12

CRC卡16、186、333

CRC cards, 16, 186, 333

createChat(),啪,129

createChat(), Smack, 129

晶莹剔透,1

Crystal Clear, 1

currentPrice(), 118–120 , 123 , 141 , 162–163

currentPrice(), 118–120, 123, 141, 162–163

currentTimeMillis(),,230java.lang.System

currentTimeMillis(), java.lang.System, 230

客户测试。请参阅 验收测试

customer tests. See acceptance tests

D

DAO(数据访问对象),297

DAO (Data Access Object), 297

数据库测试。请参阅 持久性测试

database tests. See persistence tests

DatabaseCleaner班级,291–292

DatabaseCleaner class, 291–292

数据库

databases

测试前清理,290–292

cleaning up before testing, 290–292

活跃交易的操作,300

operations with active transactions in, 300

数据驱动测试,24

data-driven tests, 24

日期操作,230–233

date manipulation, 230–233

“调试地狱”,267

“debug hell,” 267

装饰者模式,168,300

Decorator pattern, 168, 300

Defect例外,165

Defect exception, 165

依赖项,52–53,126

dependencies, 52–53, 126

单元测试中断,233

breaking in unit tests, 233

明确,14

explicit, 14

隐藏, 273

hidden, 273

隐含的,57,232–233

implicit, 57, 232–233

了解,231

knowing about, 231

循环117,129,192

loops of, 117, 129, 192

嘲讽,58

mocking, 58

用户界面组件,113

on user interface components, 113

数量,57,241–242,273

quantity of, 57, 241–242, 273

范围界定, 62

scoping, 62

使用编译器进行导航,225

using compiler for navigating, 225

依赖注入,330

dependency injections, 330

部署, 4 , 9

deployment, 4, 9

自动化,35–37

automated, 35–37

从项目开始,31

from the start of a project, 31

测试的重要性,32

importance for testing, 32

describeMismatch(),哈姆克雷斯特,343–345

describeMismatch(), Hamcrest, 343–345

describeTo(),哈姆克雷斯特,343–345

describeTo(), Hamcrest, 343–345

设计

design

改变,172

changing, 172

澄清,235

clarifying, 235

反馈,6

feedback on, 6

质量,273

quality of, 273

DeterministicExecutor类,303–304

DeterministicExecutor class, 303–304

发展

development

从输入到输出43、61

from inputs to outputs, 43, 61

增量,4,36,73,79,136,201,303

incremental, 4, 36, 73, 79, 136, 201, 303

迭代,4

iterative, 4

用户界面,183

of user interface, 183

工作妥协,90,95

working compromises during, 90, 95

disconnect(),啪,111

disconnect(), Smack, 111

disconnectWhenUICloses()111,179

disconnectWhenUICloses(), 111, 179

领域模型,15,48,59,71,290

domain model, 15, 48, 59, 71, 290

类型,213、262、269

domain types, 213, 262, 269

领域特定语言,嵌入 Java,332

domain-specific language, embedded in Java, 332

“不要重复自己”原则,248

“Don’t Repeat Yourself” principle, 248

重复262–264,273,275

duplication, 262–264, 273, 275

Dynamock 库,332

Dynamock library, 332

E

Eclipse 开发环境, 119

Eclipse development environment, 119

封装49–50,55

encapsulation, 49–50, 55

端到端测试,8-10

end-to-end tests, 8–10

异步, 87

asynchronous, 87

脆性,87

brittleness of, 87

早期,32–33

early, 32–33

失败,87

failing, 87

对于基于事件的系统,87

for event-based systems, 87

对于现有系统33、37

for existing systems, 33, 37

关于同步,313

on synchronization, 313

跑步,11

running, 11

模拟输入和输出事件, 43

simulating input and output events, 43

缓慢87,300

slowness of, 87, 300

EntityManager班级279,297,299

EntityManager class, 279, 297, 299

EntityManagerFactory类,279

EntityManagerFactory class, 279

EntityTransaction类,279

EntityTransaction class, 279

equal(),jMock,340

equal(), jMock, 340

equals(),,154java.lang.Object

equals(), java.lang.Object, 154

equalTo(),哈姆克雷斯特,322

equalTo(), Hamcrest, 322

错误消息。请参阅 失败消息

error messages. See failure messages

基于事件的系统,86–87

event-based systems, 86–87

活动,78

events, 78

外部71,326–327

external, 71, 326–327

聆听316–317,323–325

listening for, 316–317, 323–325

按顺序处理,325–326

processed in sequence, 325–326

exactly(),jMock,338

exactly(), jMock, 338

例外,22

exceptions, 22

捕捉,253–254

catching, 253–254

在隐藏线程上,302

on hidden threads, 302

运行时,165

runtime, 165

带有有用信息,330

with helpful messages, 330

Executor接口303,305

Executor interface, 303, 305

“预期意外变化”原则,45

“Expect Unexpected Changes” principle, 45

ExpectationjMock 类, 64

Expectation jMock class, 64

ExpectationCounterjMock 类,330

ExpectationCounter jMock class, 330

期望,18,27,64–66,146,254–255,277–279,338

expectations, 18, 27, 64–66, 146, 254–255, 277–279, 338

337,339

blocks of, 337, 339

检查测试主体,271

checking after test’s body, 271

清晰描述,25

clear descriptions of, 25

狭窄255,277–283

narrowness of, 255, 277–283

顺序为128,282,341–342

order of, 128, 282, 341–342

数量242–244,252

quantity of, 242–244, 252

指定要执行的操作,341

specifying actions to perform, 341

ExpectationsjMock 类, 66 , 337 , 340

Expectations jMock class, 66, 337, 340

ExpectationSetjMock 类,330

ExpectationSet jMock class, 330

ExpectationValuejMock 类,330

ExpectationValue jMock class, 330

expectFailureWithMessage()222

expectFailureWithMessage(), 222

expectSniperToFailWhenItIs()219,253

expectSniperToFailWhenItIs(), 219, 253

F

F

failed()219

failed(), 219

失败消息268–269,276

failure messages, 268–269, 276

清晰度,42

clearness of, 42

不言自明24–25,343

self-explanatory, 24–25, 343

失败,41

failures, 41

检测,217–218

detecting, 217–218

诊断,267–273,297,302–307,332

diagnostics for, 267–273, 297, 302–307, 332

展示,218–219

displaying, 218–219

处理,215–226

handling, 215–226

消息关于,255

messages about, 255

录音221–225,291

recording, 221–225, 291

边写边开发,41

writing down while developing, 41

FakeAuctionServer班级,86,89,92–95,107–110,120,176,194,254,276

FakeAuctionServer class, 86, 89, 92–95, 107–110, 120, 176, 194, 254, 276

FeatureMatcher哈姆克雷斯特级,162,178

FeatureMatcher Hamcrest class, 162, 178

反馈4,229,233

feedback, 4, 229, 233

自动部署,35–36

from automated deployment, 35–36

增量,300

incremental, 300

循环,4–5 , 8 , 40

loops of, 4–5, 8, 40

设计6,299

on design, 6, 299

失败案例,41

on failure cases, 41

关于实施,6

on implementations, 6

快速,317

rapid, 317

Findbugs,313

Findbugs, 313

固定装置,23

fixtures, 23

功能测试。请参阅 验收测试

functional tests. See acceptance tests

G

垃圾收集,23、91、101、192-194

garbage collection, 23, 91, 101, 192–194

getBody(),拍打,222

getBody(), Smack, 222

getColumnCount(),摇摆,158

getColumnCount(), Swing, 158

getValueAt(),摇摆,158

getValueAt(), Swing, 158

H

汉姆克雷斯特图书馆21、24–25、95、268、274、296、322、333、340、343–345

Hamcrest library, 21, 24–25, 95, 268, 274, 296, 322, 333, 340, 343–345

hasColumnTitles()169

hasColumnTitles(), 169

hasEnoughColumns()156–157

hasEnoughColumns(), 156–157

hashCode(),,154java.lang.Object

hashCode(), java.lang.Object, 154

hasProperty(),哈姆克雷斯特,178

hasProperty(), Hamcrest, 178

hasReceivedBid()106–107

hasReceivedBid(), 106–107

hasReceivedJoinRequestFrom(), 109 , 176

hasReceivedJoinRequestFrom(), 109, 176

hasReceivedJoinRequestFromSniper()106–108

hasReceivedJoinRequestFromSniper(), 106–108

hasShownSniperHasWon()323

hasShownSniperHasWon(), 323

hasShownSniperIsBidding(), 106 , 110

hasShownSniperIsBidding(), 106, 110

hasShownSniperIsLosing()第206–207页

hasShownSniperIsLosing(), 206–207

hasShownSniperIsWinning()140,176,323

hasShownSniperIsWinning(), 140, 176, 323

hasTitle()169

hasTitle(), 169

辅助方法7、51、66、162、166、210、226、253、263、280

helper methods, 7, 51, 66, 162, 166, 210, 226, 253, 263, 280

命名51,162

naming, 51, 162

休眠48,289,294

Hibernate, 48, 289, 294

HTTP(超文本传输​​协议),81

HTTP (HyperText Transfer Protocol), 81

I

IDE

IDEs

根据要求填写缺失的方法,119

filling in missing methods on request, 119

导航进入,114

navigation in, 114

IETF(互联网工程任务组),77

IETF (Internet Engineering Task Force), 77

ignoring(),jMock 145,278–279,339

ignoring(), jMock, 145, 278–279, 339

ignoringAuction()219

ignoringAuction(), 219

IllegalArgumentException22

IllegalArgumentException, 22

实现

implementations

反馈,6

feedback on, 6

独立于上下文,244

independent of context, 244

空, 130 , 136 , 180 , 218

null, 130, 136, 180, 218

索引卡

index cards

需要解决的技术任务,41

for technical tasks to be addressed, 41

对于待办事项列表80–81、103、120–121、130–131、148、171、182、201、211–212、225

for to-do lists, 80–81, 103, 120–121, 130–131, 148, 171, 182, 201, 211–212, 225

信息隐藏,49,55-56

information hiding, 49, 55–56

初始化程序,23

initializers, 23

inSequence() jMock ,338,341

inSequence(), jMock, 338, 341

实例,237–238

instanses, 237–238

集成测试,9–10,186–188

integration tests, 9–10, 186–188

和线程, 71

and threads, 71

难以编码,44

difficult to code, 44

对于适配器,70

for adapters, 70

对于持久性实现,300

for persistence implementations, 300

经过,40

passing, 40

速度,300

speed of, 300

IntelliJ IDEA119,250

IntelliJ IDEA, 119, 250

接口发现, 19

interface discovery, 19

接口, 14 , 58 , 61

interfaces, 14, 58, 61

回拨,71

callback, 71

实施,63-64

implementing, 63–64

嘲讽,235

mocking, 235

命名63–64,237,297

naming, 63–64, 237, 297

狭窄,63

narrowness of, 63

拉动61,63

pulling, 61, 63

重构,63–64

refactoring, 63–64

关系, 63

relationships with, 63

隔离,236

segregating, 236

祈求

invocations

允许, 27 , 146

allowed, 27, 146

受限,342

constrained, 342

计数,338–339

counting, 338–339

预期27,146

expected, 27, 146

数量,27

number of, 27

顺序279–282,341

order of, 279–282, 341

invokeAndWait()摆动100、180

invokeAndWait(), Swing, 100, 180

invokeLater(),摆动,100

invokeLater(), Swing, 100

isForSameItemAs()181

isForSameItemAs(), 181

isSatisfied()、WindowLicker,320–321

isSatisfied(), WindowLicker, 320–321

Item班级209–211,213

Item class, 209–211, 213

迭代零,83,102

iteration zero, 83, 102

J

J

Jabber。请参阅 XMPP

Jabber. See XMPP

Java 编程语言,21

Java programming language, 21

数组输入,177

arrays in, 177

收藏于,179

collections in, 179

日志记录框架,223

logging framework in, 223

方法重载, 261

method overloading in, 261

包循环,191

package loops in, 191

同步错误, 313

synchronization errors in, 313

语法噪声,253

syntax noise of, 253

使用编译器导航依赖项,225

using compiler to navigate dependencies, 225

Java EE ( Java 平台企业版),293–294,301

Java EE (Java Platform, Enterprise Edition), 293–294, 301

Java Servlet API,330

Java Servlet API, 330

JAXB(用于 XML 绑定的 Java API),289

JAXB (Java API for XML Binding), 289

JButtonSwing 组件,185

JButton Swing component, 185

JDBC(Java 数据库连接),294

JDBC (Java Database Connectivity), 294

JDO(Java 数据对象),289

JDO (Java Data Objects), 289

JFormattedTextFieldSwing 组件,208

JFormattedTextField Swing component, 208

JFrameSwing 组件, 96

JFrame Swing component, 96

JFrameDriverWindowLicker 类,91

JFrameDriver WindowLicker class, 91

JID(Jabber ID 77,197

JIDs (Jabber IDs), 77, 197

JLabel摆动组件,150

JLabel Swing component, 150

jMock库,24–27,274,332

jMock library, 24–27, 274, 332

津贴,146

allowances in, 146

双括号内,337

double braces in, 337

期望25,64–66,146

expectations in, 25, 64–66, 146

扩展至162

extensions to, 162

生成消息,345

generating messages in, 345

145 个

states in, 145

用于压力测试, 307

using for stress tests, 307

验证模拟对象,24

verifying mock objects in, 24

版本2、21、25–27、333、335–342

version 2, 21, 25–27, 333, 335–342

JMS(Java 消息服务),292

JMS (Java Messaging Service), 292

JMSTransactor班级,292

JMSTransactor class, 292

joinAuction(), 100 , 131–132 , 142 , 180–182 , 187–188 , 192 , 208

joinAuction(), 100, 131–132, 142, 180–182, 187–188, 192, 208

JPA(Java 持久性API279、289、294

JPA (Java Persistence API), 279, 289, 294

持久性标识符, 295

persistence identifiers in, 295

JTA(Java 事务 API),292

JTA (Java Transaction API), 292

JTable摆动部件,52,149–157,170

JTable Swing component, 52, 149–157, 170

JTATransactor班级,292–293

JTATransactor class, 292–293

JTextFieldSwing 组件,185

JTextField Swing component, 185

JUnit库84、274、332–333

JUnit library, 84, 274, 332–333

生成消息,345

generating messages in, 345

每个测试的新实例22,117

new instances for each test in, 22, 117

版本 4.5, 24

version 4.5, 24

版本4.6、21、335

version 4.6, 21, 335

JUnit4MockeryjMock 类,336

JUnit4Mockery jMock class, 336

大号

L

迪米特法则。参见 “告诉,不要询问”原则

Law of Demeter. See “Tell, Don’t Ask” principle

Lisp 编程语言,66

Lisp programming language, 66

文字。参见

literals. See values

302,318

locks, 302, 318

日志文件, 221–225 , 291

log files, 221–225, 291

测试前清理,221

cleaning up before testing, 221

生成, 223

generating, 223

Logger类, 223–224 , 237

Logger class, 223–224, 237

伐木,233–235

logging, 233–235

数量,235

amount of, 235

诊断,233–235

diagnostic, 233–235

隔离在一个单独的类中,226

isolated in a separate class, 226

LoggingXMPPFailureReporter班级,223–224

LoggingXMPPFailureReporter class, 223–224

LTSA 工具, 302 , 313

LTSA tool, 302, 313

M

Main类,91,101,108,117–118,123,126,132–134,142,168,178–180,183,185,188–203

Main class, 91, 101, 108, 117–118, 123, 126, 132–134, 142, 168, 178–180, 183, 185, 188–203

媒人的作用, 191

matchmaker role of, 191

main()91,96

main(), 91, 96

MainWindow类,96,100,113,134,151,156,166–167,185–187,199,208–209

MainWindow class, 96, 100, 113, 134, 151, 156, 166–167, 185–187, 199, 208–209

MainWindowTest班级186,209

MainWindowTest class, 186, 209

makeControls()第184–185页

makeControls(), 184–185

火星气候探测器灾难,59

Mars Climate Orbiter disaster, 59

Matcher界面25,268,343-345

Matcher interface, 25, 268, 343–345

匹配者, 24–25 , 95 , 155 , 157 , 276 , 322 , 339–340

matchers, 24–25, 95, 155, 157, 276, 322, 339–340

结合,24

combining, 24

定制,25,178,296,340,343-345

custom, 25, 178, 296, 340, 343–345

倒车,24

reversing, 24

无国籍者, 344

stateless, 344

Matchers哈姆克雷斯特级,340

Matchers Hamcrest class, 340

matches(),哈姆克雷斯特,343

matches(), Hamcrest, 343

会议,4

meetings, 4

MessageHandler类,217

MessageHandler class, 217

MessageListener接口,93–94、99、112–115、129、219

MessageListener interface, 93–94, 99, 112–115, 129, 219

消息, 13 , 17

messages, 13, 17

物体之间50,58

between objects, 50, 58

创建和检查同一个构造,109

creating and checking in the same construct, 109

解析,118–120 另请参阅 失败消息

parsing, 118–120 See also failure messages

方法,13

methods, 13

呼叫, 65

calling, 65

顺序,128

order of, 128

预计,339–340

expected, 339–340

工厂257–258,260–261

factory, 257–258, 260–261

吸气剂,329–330

getter, 329–330

组合在一起,176

grouping together, 176

忽略,279

ignoring, 279

命名86,173,250

naming, 86, 173, 250

超载,261

overloading, 261

副作用, 51

side effects of, 51

“糖”,65–66

“sugar,” 65–66

测试,43 另请参阅 辅助方法

testing, 43 See also helper methods

MissingValueException218

MissingValueException, 218

模拟对象18–20,25–27

mock objects, 18–20, 25–27

創作,336

creating, 336

对于第三方代码69–71、157、300

for third-party code, 69–71, 157, 300

历史,329–333

history of, 329–333

调用顺序,279–282

invocation order of, 279–282

命名,336

naming, 336

可视化协议, 58 , 61

to visualize protocols, 58, 61

嘲弄20,25

mockery, 20, 25

MockeryjMock 类, 26 , 64 , 66 , 307 , 336

Mockery jMock class, 26, 64, 66, 307, 336

嘲讽

mocking

调整, 58

adjustments, 58

课程223–224,235–237

classes, 223–224, 235–237

依赖项, 58

dependencies, 58

接口,235

interfaces, 235

通知, 58

notifications, 58

同辈,58

peers, 58

返回类型, 279

returned types, 279

第三方代码, 237

third-party code, 237

价值观,237–238

values, 237–238

月球计划,41

Moon program, 41

多线程。请参阅 线程

multithreading. See threads

N

.Net 22,232

.Net, 22, 232

“绝不在对象之间传递空值”原则,274

“Never Pass Null between Objects” principle, 274

never(),jMock,339

never(), jMock, 339

NMock 库,332

NMock library, 332

not()哈姆克雷斯特,24,340

not(), Hamcrest, 24, 340

通知52–53,126,192

notifications, 52–53, 126, 192

捕获,318–320

capturing, 318–320

嘲讽,58

mocking, 58

顺序,280

order of, 280

录音, 324

recording, 324

notifiesAuctionClosedWhenCloseMessageReceived()114

notifiesAuctionClosedWhenCloseMessageReceived(), 114

notifiesAuctionFailedWhenBadMessageReceived()217

notifiesAuctionFailedWhenBadMessageReceived(), 217

notifiesAuctionFailedWhenEventTypeMissing()218

notifiesAuctionFailedWhenEventTypeMissing(), 218

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromOtherBidder()141

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromOtherBidder(), 141

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromSniper()141

notifiesBidDetailsWhenCurrentPriceMessageReceivedFromSniper(), 141

notToBeGCd字段,101,179,197,200,203

notToBeGCd field, 101, 179, 197, 200, 203

NullPointerException53,274

NullPointerException, 53, 274

NUnit库,22,117,332

NUnit library, 22, 117, 332

O

对象母模式,257–258

object mother pattern, 257–258

面向对象编程13,329

object-oriented programming, 13, 329

对象

objects

抽象级别,57

abstraction level of, 57

236之间的关系

bringing out relationships between, 236

合作,18–20,52–53,58,60–62,186

collaborating, 18–20, 52–53, 58, 60–62, 186

沟通,13–14,50,58,244–245

communicating, 13–14, 50, 58, 244–245

复合材料,53–54

composite, 53–54

上下文无关, 54–55 , 233

context-independent, 54–55, 233

由建筑工人建造,259–260

created by builders, 259–260

难以脱钩,273

difficult to decouple, 273

可变,14

mutable, 14

分享参考,50

sharing references to, 50

命名62,244

naming, 62, 244

空, 22 , 115 , 130 , 242

null, 22, 115, 130, 242

关于并发性的可观察不变量,306

observable invariants with respect to concurrency of, 306

被动,311–312

passive, 311–312

持续性,298–299

persistent, 298–299

简化,55

simplifying, 55

单一职责,51–52

single responsibility of, 51–52

州, 13 , 59 , 145–146 , 281–283 , 299 , 306 , 342

states of, 13, 59, 145–146, 281–283, 299, 306, 342

下属254,291–292,311

subordinate, 254, 291–292, 311

示踪剂,270–271

tracer, 270–271

有效性, 53

validity of, 53

与价值观, 13–14 , 51 , 59

vs. values, 13–14, 51, 59

网络13,64–65

web of, 13, 64–65

oneOf()jMock278,337–338

oneOf(), jMock, 278, 337–338

Openfire86,89,95

Openfire, 86, 89, 95

ORM(对象/关系映射289,297,299

ORM (Object/Relational Mapping), 289, 297, 299

P

packages

循环,191

loops of, 191

单一职责,52

single responsibility of, 52

结对编程,4

pair programming, 4

图案,命名,297

patterns, naming after, 297

同辈,50

peers, 50

嘲讽,58

mocking, 58

类型,52–53

types of, 52–53

持久性测试,289–300

persistence tests, 289–300

和交易,292–294

and transactions, 292–294

开始时进行清理,291

cleaning up at the start, 291

故障诊断,297

failure diagnostics in, 297

相互隔离,290–292

isolating from one another, 290–292

往返,297–300

round-trip, 297–300

缓慢,300

slowness of, 300

Poller班级,320–321

Poller class, 320–321

轮询变更317、320–321、323–325

polling for changes, 317, 320–321, 323–325

PortfolioListener接口, 199

PortfolioListener interface, 199

端口, 48

ports, 48

“端口和适配器” 架构, 48 , 201 , 284 , 297

“ports and adapters” architecture, 48, 201, 284, 297

PriceSource枚举141,148

PriceSource enumeration, 141, 148

Probe接口,320–322

Probe interface, 320–322

探测系统,315,320-322

probing a system, 315, 320–322

processMessage()、打击、114–115、135–136、217、219

processMessage(), Smack, 114–115, 135–136, 217, 219

生产环境, 95

production environment, 95

编程风格,51

programming styles, 51

进度测量4,40

progress measuring, 4, 40

PropertyMatcher哈姆克雷斯特级,178

PropertyMatcher Hamcrest class, 178

Q

查询, 278

queries, 278

R

R

receivesAMessageMatching()108

receivesAMessageMatching(), 108

重新设计,7

redesign, 7

重构,5-7

refactoring, 5–7

代码难以测试,44-45

code difficult to test, 44–45

在 TDD 期间的重要性,225–226

importance of, during TDD, 225–226

增量,202

incremental, 202

边写边开发,41

writing down while developing, 41

引用类型, 269

reference types, 269

回归套件6,40

regression suites, 6, 40

回归测试,5

regression tests, 5

发布, 4 , 9

releases, 4, 9

规划,81

planning, 81

到生产系统,35

to a production system, 35

removeMessageListener(),拍打,220

removeMessageListener(), Smack, 220

reportPrice()106–107176

reportPrice(), 106–107, 176

reportsInvalidMessage()216,221

reportsInvalidMessage(), 216, 221

reportsLostIfAuctionClosesImmediately()145

reportsLostIfAuctionClosesImmediately(), 145

reportsLostIfAuctionClosesWhenBidding()146

reportsLostIfAuctionClosesWhenBidding(), 146

存储库模式, 297

repository pattern, 297

resetLogging()223

resetLogging(), 223

职责16,171,220,222

responsibilities, 16, 171, 220, 222

数量,61,240–241,332单一责任原则

quantity of, 61, 240–241, 332 See also “single responsibility” principle

恢复更改, 267

reverting changes, 267

攀岩,202

rock climbing, 202

角色,16

roles, 16

rollback()279

rollback(), 279

回滚,267

rolling back, 267

Ruby 编程语言,331

Ruby programming language, 331

Rule注释, 24

Rule annotation, 24

RuntimeException255,277

RuntimeException, 255, 277

runUntilIdle()304

runUntilIdle(), 304

@RunWith注释23,26,336

@RunWith annotation, 23, 26, 336

年代

S

safelyAddItemToModel()180,188

safelyAddItemToModel(), 180, 188

same(),jMock,340

same(), jMock, 340

sample()、WindowLicker,320–321

sample(), WindowLicker, 320–321

计划活动,326–327

scheduled activities, 326–327

Scrum 项目,1

Scrum projects, 1

SelfDescribing接口,343

SelfDescribing interface, 343

sendInvalidMessageContaining()216

sendInvalidMessageContaining(), 216

SequencejMock 类,341–342

Sequence jMock class, 341–342

序列279–282,341–342

sequences, 279–282, 341–342

servlet 301,311

servlets, 301, 311

setImposteriser(),jMock,223

setImposteriser(), jMock, 223

setStatusText()166

setStatusText(), 166

[Setup]方法,22

[Setup] methods, 22

showsSniperHasFailed()216

showsSniperHasFailed(), 216

showsSniperHasWonAuction()140,176

showsSniperHasWonAuction(), 140, 176

showsSniperStatus()91–92

showsSniperStatus(), 91–92

“单一职责”原则51–52、113、123、125、220、222

“single responsibility” principle, 51–52, 113, 123, 125, 220, 222

SingleMessageListener班级93–94,107–108

SingleMessageListener class, 93–94, 107–108

单例模式,50,230

singleton pattern, 50, 230

斯麦克图书馆,86

Smack library, 86

例外情况,217

exceptions in, 217

线程数, 93 , 301

threads in, 93, 301

Smalltalk 编程语言

Smalltalk programming language

级联258,330,332

cascade, 258, 330, 332

与 Java 相比的编程风格,330

programming style compared to Java, 330

狙击手应用程序。请参阅 拍卖狙击手

Sniper application. See Auction Sniper

Sniper类,62

Sniper class, 62

sniperAdded()203

sniperAdded(), 203

sniperBidding()126–128、155、160–162

sniperBidding(), 126–128, 155, 160–162

SniperCollector班级62,198–199,245

SniperCollector class, 62, 198–199, 245

sniperForItem()198

sniperForItem(), 198

SniperLauncher班级62,197–199,210

SniperLauncher class, 62, 197–199, 210

SniperListener接口,124–126,133,154–155,163–164,168

SniperListener interface, 124–126, 133, 154–155, 163–164, 168

sniperLost()125,147,164

sniperLost(), 125, 147, 164

sniperMakesAHigherBidButLoses()139

sniperMakesAHigherBidButLoses(), 139

SniperPortfolio班级,199–203

SniperPortfolio class, 199–203

sniperReportsInvalidAuctionMessageAndStopsRespondingToEvents()216

sniperReportsInvalidAuctionMessageAndStopsRespondingToEvents(), 216

SniperSnapshot班级,159–164,173,180–181,198–199,211,219,278

SniperSnapshot class, 159–164, 173, 180–181, 198–199, 211, 219, 278

SnipersTableModel类,149,151–152,156,166,168,170–171,180–182,185,197–201,207

SnipersTableModel class, 149, 151–152, 156, 166, 168, 170–171, 180–182, 185, 197–201, 207

SniperState类,155,158–161,207,216,278

SniperState class, 155, 158–161, 207, 216, 278

sniperStateChanged()156–164,278

sniperStateChanged(), 156–164, 278

SniperStateDisplayer类,133,147,155,167-168

SniperStateDisplayer class, 133, 147, 155, 167–168

sniperWinning()143、162–163

sniperWinning(), 143, 162–163

sniperWinsAnAuctionByBiddingHigher()139

sniperWinsAnAuctionByBiddingHigher(), 139

sniperWon()147,164

sniperWon(), 147, 164

春季,294

Spring, 294

startBiddingFor()184

startBiddingFor(), 184

startBiddingIn()177

startBiddingIn(), 177

startBiddingWithStopPrice()第206–207页

startBiddingWithStopPrice(), 206–207

startSellingItem()92,176

startSellingItem(), 92, 176

startSniper()第183–184页

startSniper(), 183–184

startsWith(),哈姆克雷斯特,343–345

startsWith(), Hamcrest, 343–345

状态279–282,342

state machines, 279–282, 342

状态转换图,212

state transition diagrams, 212

StatesjMock类,146,198,281-283

States jMock class, 146, 198, 281–283

静态分析工具, 313

static analysis tools, 313

止损价,80,205-213

stop price, 80, 205–213

压力测试,306–313

stress tests, 306–313

失败308–309,313

failing, 308–309, 313

事件处理顺序, 326

on event processing order, 326

被动对象,311–312

on passive objects, 311–312

在不同环境中运行, 313

running in different environments, 313

字符串

strings

检查是否以给定的前缀开头,343–345

checking if starts with a given prefix, 343–345

比较,14

comparing, 14

类型相比,213、262、269

vs. domain types, 213, 262, 269

StringStartsWithMatcher哈姆克雷斯特级,345

StringStartsWithMatcher Hamcrest class, 345

存根84,243,277,339​​

stubs, 84, 243, 277, 339

成功案例,41

success cases, 41

摇摆

Swing

操纵特征,90

manipulating features in, 90

测试,86–87

testing, 86–87

线程数,123,133,180,301

threads in, 123, 133, 180, 301

SwingThreadSniperListener接口168,197,199

SwingThreadSniperListener interface, 168, 197, 199

SynchroniserjMock类,307–308,312–313

Synchroniser jMock class, 307–308, 312–313

同步,301–314

synchronizations, 301–314

错误,302

errors in, 302

测试,302,306–310,313

testing, 302, 306–310, 313

与断言相比,326

vs. assertions, 326

系统

system

应用模型, 48

application model of, 48

改变行为48,55

changing behavior of, 48, 55

并发架构,301–302

concurrency architecture of, 301–302

可维护性,47

maintainability of, 47

开发过程中的公开图纸,34

public drawings of, during development, 34

测试后返回初始状态,323

returning to initial state after a test, 323

简化,112

simplifying, 112

系统测试。请参阅 验收测试

system tests. See acceptance tests

电视

T

tableChanged(), 摇摆, 157 , 181

tableChanged(), Swing, 157, 181

TableModel149,168–171

TableModel class, 149, 168–171

TableModelEvent157,180–181

TableModelEvent class, 157, 180–181

TableModelListener班级,156–157

TableModelListener class, 156–157

任务运行者, 303

task runners, 303

TDD (测试驱动开发1,5,229

TDD (Test-Driven Development), 1, 5, 229

周期,6,39–45,271–272

cycle of, 6, 39–45, 271–272

对于现有系统,37

for existing systems, 37

黄金法则,6

golden rule of, 6

启动,31-37

kick-starting, 31–37

可持续,227–285

sustainable, 227–285

[TearDown]方法,22

[TearDown] methods, 22

“告诉,不要询问原则17,54,245

“Tell, Don’t Ask” principle, 17, 54, 245

模板方法,344

template methods, 344

测试数据构建器,238、258–259

test data builders, 238, 258–259

交易内调用,300

calling within transactions, 300

结合261,300

combining, 261, 300

创建类似的对象,259–260

creating similar objects with, 259–260

列表,298–299

lists of, 298–299

删除重复项,262–264

removing duplication with, 262–264

工厂方法的总结261

wrapping up in factory methods, 261

测试运行者,23–24

test runner, 23–24

JMock,26岁

JMock, 26

Parameterized24

Parameterized, 24

测试气味” ,229,235,248

“test smells,” 229, 235, 248

聆听的好处,244–246

benefits of listening to, 244–246

@Test注释, 22

@Test annotation, 22

TestDox 惯例,249–250

TestDox convention, 249–250

测试驱动开发。参见 TDD

Test-Driven Development. See TDD

测试

tests

打击虚假服务84、88、93

against fake services, 84, 88, 93

针对实际服务,32、88、93

against real services, 32, 88, 93

异步,315–327

asynchronous, 315–327

在项目开始36、41

at the beginning of a project, 36, 41

脆性229,255,257,273​​

brittleness of, 229, 255, 257, 273

清理245,248,273

cleaning up, 245, 248, 273

与测试对象解耦,278

decoupling from tested objects, 278

依赖项,275

dependencies in, 275

明确约束,280

explicit constraints in, 280

失败,267–273

failing, 267–273

灵活性,273–285

flexibility of, 273–285

闪烁,317

flickering, 317

重点273,277,279,279​​

focused, 273, 277, 279, 279

对于后期整合,36

for late integration, 36

等级,9–10

hierarchy of, 9–10

维护247,273–274

maintaining, 247, 273–274

命名, 44 , 248–250 , 252 , 264 , 268 , 326

naming, 44, 248–250, 252, 264, 268, 326

可读性247–257,273,280

readability of, 247–257, 273, 280

重复性,23

repeatability of, 23

逃亡,322–323

runaway, 322–323

跑步,6

running, 6

抽样316–317,320–325

sampling, 316–317, 320–325

不言自明,274–275

self-explanatory, 274–275

单独包装,114

separate packages for, 114

大小, 45 , 268

size of, 45, 268

各州,283

states of, 283

同步301–314,317

synchronizing, 301–314, 317

使用后台线程,312–313

with background threads, 312–313

紧密耦合,273

tightly coupled, 273

触发可检测行为,325

triggering detectable behavior, 325

写作,6

writing, 6

向后,252

backwards, 252

以标准形式,251–252 另请 参阅验收测试端到端测试集成测试持久性测试单元测试

in a standard form, 251–252 See also acceptance tests, end-to-end tests, integration tests, persistence tests, unit tests

textFor()166

textFor(), 166

“最简单的办法可能行得通”,41

“the simplest thing that could possibly work,” 41

then()jMock281–282,338,342

then(), jMock, 281–282, 338, 342

第三方代码,69–72

third-party code, 69–72

抽象超过10

abstractions over, 10

嘲讽,69–71,157,237,300

mocking, 69–71, 157, 237, 300

修补, 69

patching, 69

测试集成186–188,289

testing integration with, 186–188, 289

值类型来自, 71

value types from, 71

托尔自动魔法,12

Thor Automagic, 12

线程71,301–315

threads, 71, 301–315

调度, 313

scheduling, 313

三点接触,202

three-point contact, 202

时间盒,4

time boxes, 4

Timeout类, 318 , 322

Timeout class, 318, 322

超时230,312–313,316–318

timeouts, 230, 312–313, 316–318

时间戳,276

timestamps, 276

toString(),,154java.lang.Object

toString(), java.lang.Object, 154

示踪物体,270–271

tracer object, 270–271

“火车失事代码,17、50–51、65

“train wreck” code, 17, 50–51, 65

交易管理, 294

transaction management, 294

交易者,292–293

transactors, 292–293

translate()217

translate(), 217

translatorFor()220,226,253

translatorFor(), 220, 226, 253

TypeSafeMatcher<String>哈姆克雷斯特级,344

TypeSafeMatcher<String> Hamcrest class, 344

U

单元测试, 4 , 9

unit tests, 4, 9

针对静态全局对象, 234

against static global objects, 234

和线程,301–314

and threads, 301–314

在项目开始时,43

at the beginning of a project, 43

打破依赖关系,233

breaking dependencies in, 233

脆性,245

brittleness of, 245

难以编码,44

difficult to code, 44

失败,8

failing, 8

相互隔离22,117

isolating from each other, 22, 117

长度,245–246

length of, 245–246

限制范围,57

limiting scope of, 57

命名114,141

naming, 114, 141

行为而非方法,43

on behavior, not methods, 43

合作对象,18-20

on collaborating objects, 18–20

关于同步302,306–310,313

on synchronization, 302, 306–310, 313

经过,40

passing, 40

可读性,245–246

readability of, 245–246

简化,62

simplifying, 62

速度300,312

speed of, 300, 312

结构,335–342

structure of, 335–342

写作,11

writing, 11

Unix,66

Unix, 66

用户体验社区, 81 , 212

User Experience community, 81, 212

用户界面

user interface

配置通过, 242

configuring through, 242

依赖项,113

dependencies on, 113

处理用户请求, 186

handling user requests, 186

支持登录, 233

support logging in, 233

开发并行工作,183、212

working on parallel to development, 183, 212

UserRequestListener界面,186–188,208–209,213

UserRequestListener interface, 186–188, 208–209, 213

V

类型,59–60,141

value types, 59–60, 141

来自第三方代码,71

from third-party code, 71

助手,59

helper, 59

命名,173

naming, 173

占位符, 59 , 209

placeholder, 59, 209

公共最终字段,154

public final fields in, 154

与价值观相比,59

vs. values, 59

使用泛型,136

with generics, 136

valueIn()第166–167页

valueIn(), 166–167

ValueMatcherProbeWindowLicker 类,187

ValueMatcherProbe WindowLicker class, 187

值,255–256

values, 255–256

比较,22

comparing, 22

预期,127

expected, 127

不可50,59

immutable, 50, 59

嘲讽,237–238

mocking, 237–238

可变,50

mutable, 50

显然是罐装的,270

obviously canned, 270

自我描述269,285

self-describing, 269, 285

副作用, 51

side effects of, 51

物体相对照,13–14,51,59

vs. objects, 13–14, 51, 59

变量,255–256

variables, 255–256

全球,50

global, 50

命名209,330

naming, 209, 330

西

W

waitForAnotherAuctionEvent()216

waitForAnotherAuctionEvent(), 216

waitUntil()326

waitUntil(), 326

行走的骨架,32–37

walking skeleton, 32–37

拍卖狙击手》79、83–88

for Auction Sniper, 79, 83–88

when()jMock281–282,338,342

when(), jMock, 281–282, 338, 342

whenAuctionClosed()第164–165页

whenAuctionClosed(), 164–165

will() jMock ,338,341

will(), jMock, 338, 341

WindowAdapter类,134

WindowAdapter class, 134

WindowLicker 库, 24 , 86–87 , 186–187 , 254 , 316

WindowLicker library, 24, 86–87, 186–187, 254, 316

控制 Swing 组件,90–91

controlling Swing components in, 90–91

错误消息, 96

error messages in, 96

with(),jMock,339–340

with(), jMock, 339–340

超载,261

overloaded, 261

X

XmlMarshaller班级,284–285

XmlMarshaller class, 284–285

XmlMarshallerTest班级,284

XmlMarshallerTest class, 284

XMPP (可扩展消息和状态协议),76–77,105,203

XMPP (eXtensible Messaging and Presence Protocol), 76–77, 105, 203

消息进来,301

messages in, 301

可靠性,81

reliability of, 81

安全,81

security of, 81

XMPP 消息代理, 84 , 86 , 95

XMPP message brokers, 84, 86, 95

XMPPAuction类, 62 , 131–132 , 192–197 , 203 , 224

XMPPAuction class, 62, 131–132, 192–197, 203, 224

XMPPAuctionException224

XMPPAuctionException, 224

XMPPAuctionHouse类,62,196–197,203,224

XMPPAuctionHouse class, 62, 196–197, 203, 224

XMPPConnection班级,195–197

XMPPConnection class, 195–197

XMPPException130

XMPPException, 130

XMPPFailureReporter类, 222–223 , 226

XMPPFailureReporter class, 222–223, 226

XP(极限编程1,41,331

XP (Extreme Programming), 1, 41, 331

XStream,289

XStream, 289

XTC(伦敦极限星期二俱乐部),331

XTC (London Extreme Tuesday Club), 331

图像
图像
图像

图像
图像
图像
图像
图像

图像

图像
图像

图像

图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像
图像

图像

图像
图像
图像
图像
图像
图像
图像
图像
图像

图像
图像
图像